Skip to content

Commit 690bdac

Browse files
committed
feat(attachments): add delete functionality to AttachmentTransportAdapter
Enhance AttachmentTransportAdapter to support file deletion operations. This includes updates to ExpoFileSystemTransportAdapter and ReactNativeFSTransportAdapter to implement the new deleteFile method. Additionally, adjustments were made to AttachmentQueue and BufferedAttachmentTransport to integrate the delete functionality into the attachment synchronization process.
1 parent f7c18a1 commit 690bdac

8 files changed

Lines changed: 142 additions & 21 deletions

File tree

packages/attachments-storage-react-native/src/ExpoFileSystemTransportAdapter.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { AttachmentTransportAdapter, LocatedAttachmentRecord } from '@powersync/common';
1+
import type { AttachmentRecord, AttachmentTransportAdapter, LocatedAttachmentRecord } from '@powersync/common';
22

33
/**
44
* Describes the HTTP request used to upload a file's bytes to remote storage.
@@ -44,6 +44,11 @@ export interface ExpoFileSystemTransportAdapterOptions {
4444
resolveUpload: (attachment: LocatedAttachmentRecord) => Promise<ExpoUploadRequest> | ExpoUploadRequest;
4545
/** Resolves the download request (e.g. a presigned URL) for an attachment. */
4646
resolveDownload: (attachment: LocatedAttachmentRecord) => Promise<ExpoDownloadRequest> | ExpoDownloadRequest;
47+
/**
48+
* Deletes the attachment's file from remote storage (e.g. a storage SDK call or a
49+
* `DELETE` request). Delete is a plain remote operation, not a file transfer.
50+
*/
51+
deleteFile: (attachment: AttachmentRecord) => Promise<void>;
4752
}
4853

4954
type ExpoUploadResult = { status: number; body?: string };
@@ -134,6 +139,10 @@ To use the Expo File System transport please install expo-file-system.`);
134139
}
135140
}
136141

142+
async delete(attachment: AttachmentRecord): Promise<void> {
143+
await this.options.deleteFile(attachment);
144+
}
145+
137146
private isOk(status: number): boolean {
138147
return status >= 200 && status < 300;
139148
}

packages/attachments-storage-react-native/src/ReactNativeFSTransportAdapter.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { AttachmentTransportAdapter, LocatedAttachmentRecord } from '@powersync/common';
1+
import type { AttachmentRecord, AttachmentTransportAdapter, LocatedAttachmentRecord } from '@powersync/common';
22

33
/**
44
* Describes the HTTP request used to upload a file's bytes to remote storage.
@@ -44,6 +44,11 @@ export interface ReactNativeFSTransportAdapterOptions {
4444
resolveDownload: (
4545
attachment: LocatedAttachmentRecord
4646
) => Promise<ReactNativeFSDownloadRequest> | ReactNativeFSDownloadRequest;
47+
/**
48+
* Deletes the attachment's file from remote storage (e.g. a storage SDK call or a
49+
* `DELETE` request). Delete is a plain remote operation, not a file transfer.
50+
*/
51+
deleteFile: (attachment: AttachmentRecord) => Promise<void>;
4752
}
4853

4954
/**
@@ -112,6 +117,10 @@ To use the React Native File System transport please install @dr.pogodin/react-n
112117
}
113118
}
114119

120+
async delete(attachment: AttachmentRecord): Promise<void> {
121+
await this.options.deleteFile(attachment);
122+
}
123+
115124
/** react-native-fs expects plain filesystem paths, not `file://` URIs. */
116125
private toPath(uri: string): string {
117126
return uri.startsWith('file://') ? uri.slice('file://'.length) : uri;

packages/common/etc/common.api.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ export class AttachmentQueue {
9696
generateAttachmentId(): Promise<string>;
9797
readonly localStorage: LocalStorageAdapter;
9898
readonly logger: PowerSyncLogger;
99-
readonly remoteStorage: RemoteStorageAdapter;
99+
readonly remoteStorage?: RemoteStorageAdapter;
100100
saveFile(input: {
101101
data: AttachmentData;
102102
fileExtension: string;
@@ -131,7 +131,7 @@ export interface AttachmentQueueOptions {
131131
errorHandler?: AttachmentErrorHandler;
132132
localStorage: LocalStorageAdapter;
133133
logger?: PowerSyncLogger;
134-
remoteStorage: RemoteStorageAdapter;
134+
remoteStorage?: RemoteStorageAdapter;
135135
syncIntervalMs?: number;
136136
syncThrottleDuration?: number;
137137
tableName?: string;
@@ -189,6 +189,7 @@ export type AttachmentTableRecord = RowType<AttachmentTable>;
189189

190190
// @alpha
191191
export interface AttachmentTransportAdapter {
192+
delete(attachment: AttachmentRecord): Promise<void>;
192193
download(attachment: LocatedAttachmentRecord): Promise<void>;
193194
upload(attachment: LocatedAttachmentRecord): Promise<void>;
194195
}
@@ -261,6 +262,8 @@ export interface BatchedUpdateNotification {
261262
export class BufferedAttachmentTransport implements AttachmentTransportAdapter {
262263
constructor(localStorage: LocalStorageAdapter, remoteStorage: RemoteStorageAdapter);
263264
// (undocumented)
265+
delete(attachment: AttachmentRecord): Promise<void>;
266+
// (undocumented)
264267
download(attachment: LocatedAttachmentRecord): Promise<void>;
265268
// (undocumented)
266269
upload(attachment: LocatedAttachmentRecord): Promise<void>;

packages/common/src/attachments/AttachmentQueue.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,29 @@ export interface AttachmentQueueOptions {
2626
*/
2727
db: CommonPowerSyncDatabase;
2828
/**
29-
* Remote storage adapter for upload/download operations
29+
* Remote storage adapter for upload/download/delete operations. Used to build the
30+
* default {@link BufferedAttachmentTransport}, which delegates all three operations
31+
* to it.
32+
*
33+
* Required unless a {@link AttachmentQueueOptions.transportAdapter} is provided. If
34+
* both are provided, `transportAdapter` takes precedence and `remoteStorage` is
35+
* completely unused.
3036
*/
31-
remoteStorage: RemoteStorageAdapter;
37+
remoteStorage?: RemoteStorageAdapter;
3238
/**
3339
* Local storage adapter for file persistence
3440
*/
3541
localStorage: LocalStorageAdapter;
3642
/**
37-
* Transport responsible for moving attachment bytes between local and remote storage.
43+
* Transport responsible for all remote attachment operations (upload/download/delete).
44+
*
3845
* Defaults to a {@link BufferedAttachmentTransport} composed from `localStorage` and
3946
* `remoteStorage`. Provide a custom transport (e.g. a native file-URI implementation)
4047
* to transfer large files without materializing them in JS memory.
48+
*
49+
* When provided, the transport handles **all** remote operations directly and does
50+
* not use `remoteStorage` — any `remoteStorage` passed alongside it is ignored, so
51+
* implement delete on the transport itself.
4152
*/
4253
transportAdapter?: AttachmentTransportAdapter;
4354
/**
@@ -92,8 +103,8 @@ export class AttachmentQueue {
92103
/** Adapter for local file storage operations */
93104
readonly localStorage: LocalStorageAdapter;
94105

95-
/** Adapter for remote file storage operations */
96-
readonly remoteStorage: RemoteStorageAdapter;
106+
/** Adapter for remote file storage operations. Undefined when a custom `transportAdapter` is used. */
107+
readonly remoteStorage?: RemoteStorageAdapter;
97108

98109
/**
99110
* Callback function to watch for changes in attachment references in your data model.
@@ -190,11 +201,16 @@ export class AttachmentQueue {
190201
this.downloadAttachments = downloadAttachments;
191202
this.logger = logger ?? db.logger;
192203
this.attachmentService = new AttachmentService(db, this.logger, tableName, archivedCacheLimit);
204+
205+
if (!transportAdapter && !remoteStorage) {
206+
throw new Error('AttachmentQueue requires either a `remoteStorage` or a `transportAdapter`.');
207+
}
208+
const transport = transportAdapter ?? new BufferedAttachmentTransport(localStorage, remoteStorage!);
209+
193210
this.syncingService = new SyncingService(
194211
this.attachmentService,
195212
localStorage,
196-
remoteStorage,
197-
transportAdapter ?? new BufferedAttachmentTransport(localStorage, remoteStorage),
213+
transport,
198214
this.logger,
199215
errorHandler
200216
);

packages/common/src/attachments/AttachmentTransportAdapter.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,18 @@ import { AttachmentRecord } from './Schema.js';
1111
export type LocatedAttachmentRecord = AttachmentRecord & { localUri: string };
1212

1313
/**
14-
* AttachmentTransportAdapter models the movement of an attachment's bytes between
15-
* local storage and remote storage as a single operation.
14+
* AttachmentTransportAdapter owns all remote-side operations for an attachment
15+
* transfer (upload/download) and delete — as single operations.
1616
*
1717
* A transport owns the entire transfer, letting implementations pick the most
1818
* efficient mechanism available (buffer, stream, or a platform-native file-URI
1919
* upload/download API). On platforms like React Native this allows large files to
2020
* be transferred without ever materializing them in the JS heap.
2121
*
2222
* {@link BufferedAttachmentTransport} is the default, composing the local and
23-
* remote storage adapters.
23+
* remote storage adapters. Provide a custom transport (via
24+
* `AttachmentQueue`'s `transportAdapter` option) to own the whole remote side; in
25+
* that case a separate `remoteStorage` is not required.
2426
*
2527
* @experimental
2628
* @alpha This is currently experimental and may change without a major version bump.
@@ -38,4 +40,11 @@ export interface AttachmentTransportAdapter {
3840
* assigned by the syncing service before this call.
3941
*/
4042
download(attachment: LocatedAttachmentRecord): Promise<void>;
43+
44+
/**
45+
* Deletes the attachment's file from remote storage. Local file removal is handled
46+
* separately by the syncing service.
47+
* @param attachment - The attachment to delete.
48+
*/
49+
delete(attachment: AttachmentRecord): Promise<void>;
4150
}

packages/common/src/attachments/BufferedAttachmentTransport.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { AttachmentTransportAdapter, LocatedAttachmentRecord } from './AttachmentTransportAdapter.js';
22
import { LocalStorageAdapter } from './LocalStorageAdapter.js';
33
import { RemoteStorageAdapter } from './RemoteStorageAdapter.js';
4+
import { AttachmentRecord } from './Schema.js';
45

56
/**
67
* Default {@link AttachmentTransportAdapter}, composing the local and remote
@@ -29,4 +30,8 @@ export class BufferedAttachmentTransport implements AttachmentTransportAdapter {
2930
const fileData = await this.remoteStorage.downloadFile(attachment);
3031
await this.localStorage.saveFile(attachment.localUri, fileData);
3132
}
33+
34+
async delete(attachment: AttachmentRecord): Promise<void> {
35+
await this.remoteStorage.deleteFile(attachment);
36+
}
3237
}

packages/common/src/attachments/SyncingService.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { LogLevels, PowerSyncLogger } from '../utils/Logger.js';
22
import { AttachmentService } from './AttachmentService.js';
33
import { AttachmentTransportAdapter } from './AttachmentTransportAdapter.js';
44
import { LocalStorageAdapter } from './LocalStorageAdapter.js';
5-
import { RemoteStorageAdapter } from './RemoteStorageAdapter.js';
65
import { AttachmentRecord, AttachmentState } from './Schema.js';
76
import { AttachmentErrorHandler } from './AttachmentErrorHandler.js';
87
import { AttachmentContext } from './AttachmentContext.js';
@@ -11,27 +10,27 @@ import { AttachmentContext } from './AttachmentContext.js';
1110
* Orchestrates attachment synchronization between local and remote storage.
1211
* Handles uploads, downloads, deletions, and state transitions.
1312
*
13+
* Remote operations (upload/download/delete) go through the {@link AttachmentTransportAdapter};
14+
* local file operations use the {@link LocalStorageAdapter}.
15+
*
1416
* @internal
1517
*/
1618
export class SyncingService {
1719
private attachmentService: AttachmentService;
1820
private localStorage: LocalStorageAdapter;
19-
private remoteStorage: RemoteStorageAdapter;
2021
private transport: AttachmentTransportAdapter;
2122
private logger: PowerSyncLogger;
2223
private errorHandler?: AttachmentErrorHandler;
2324

2425
constructor(
2526
attachmentService: AttachmentService,
2627
localStorage: LocalStorageAdapter,
27-
remoteStorage: RemoteStorageAdapter,
2828
transport: AttachmentTransportAdapter,
2929
logger: PowerSyncLogger,
3030
errorHandler?: AttachmentErrorHandler
3131
) {
3232
this.attachmentService = attachmentService;
3333
this.localStorage = localStorage;
34-
this.remoteStorage = remoteStorage;
3534
this.transport = transport;
3635
this.logger = logger;
3736
this.errorHandler = errorHandler;
@@ -182,7 +181,7 @@ export class SyncingService {
182181
*/
183182
async deleteAttachment(attachment: AttachmentRecord, context: AttachmentContext): Promise<AttachmentRecord> {
184183
try {
185-
await this.remoteStorage.deleteFile(attachment);
184+
await this.transport.delete(attachment);
186185
if (attachment.localUri) {
187186
await this.localStorage.deleteFile(attachment.localUri);
188187
}

packages/node/tests/attachments.test.ts

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -796,7 +796,8 @@ describe('attachment queue - transport', () => {
796796
const transportUpload = vi.fn().mockResolvedValue(undefined);
797797
const transportAdapter: AttachmentTransportAdapter = {
798798
upload: transportUpload,
799-
download: vi.fn().mockResolvedValue(undefined)
799+
download: vi.fn().mockResolvedValue(undefined),
800+
delete: vi.fn().mockResolvedValue(undefined)
800801
};
801802

802803
const q = new AttachmentQueue({
@@ -841,7 +842,8 @@ describe('attachment queue - transport', () => {
841842
});
842843
const transportAdapter: AttachmentTransportAdapter = {
843844
upload: vi.fn().mockResolvedValue(undefined),
844-
download: transportDownload
845+
download: transportDownload,
846+
delete: vi.fn().mockResolvedValue(undefined)
845847
};
846848

847849
const q = new AttachmentQueue({
@@ -878,6 +880,75 @@ describe('attachment queue - transport', () => {
878880
const record = attachments.find((r) => r.id === id)!;
879881
expect(await mockLocalStorage.fileExists(record.localUri!)).toBe(true);
880882
});
883+
884+
// A custom transport replaces remoteStorage for ALL remote operations. Upload and
885+
// download are covered by the two tests above; this covers the delete slice.
886+
it('delete goes through the custom transport (not remoteStorage)', async () => {
887+
const transportDelete = vi.fn().mockResolvedValue(undefined);
888+
const transportAdapter: AttachmentTransportAdapter = {
889+
upload: vi.fn().mockResolvedValue(undefined),
890+
download: vi.fn(async (attachment: AttachmentRecord & { localUri: string }) => {
891+
await mockLocalStorage.saveFile(attachment.localUri, createMockJpegBuffer());
892+
}),
893+
delete: transportDelete
894+
};
895+
896+
const q = new AttachmentQueue({
897+
db,
898+
watchAttachments,
899+
remoteStorage: mockRemoteStorage,
900+
localStorage: mockLocalStorage,
901+
transportAdapter,
902+
syncIntervalMs: INTERVAL_MILLISECONDS,
903+
archivedCacheLimit: 0
904+
});
905+
onTestFinished(() => q.stopSync());
906+
907+
await q.startSync();
908+
909+
const id = await q.generateAttachmentId();
910+
await db.execute('INSERT INTO users (id, name, email, photo_id) VALUES (uuid(), ?, ?, ?)', [
911+
'deleter',
912+
'deleter@example.com',
913+
id
914+
]);
915+
await waitForMatchCondition(
916+
() => watchAttachmentsTable(),
917+
(results) => results.some((r) => r.id === id && r.state === AttachmentState.SYNCED),
918+
5
919+
);
920+
921+
await q.deleteFile({
922+
id,
923+
updateHook: async (tx) => {
924+
await tx.execute('UPDATE users SET photo_id = NULL WHERE photo_id = ?', [id]);
925+
}
926+
});
927+
928+
await waitForMatchCondition(
929+
() => watchAttachmentsTable(),
930+
(results) => !results.some((r) => r.id === id),
931+
5
932+
);
933+
934+
// With a custom transport present, it owns delete; the passed-alongside
935+
// remoteStorage.deleteFile is not used.
936+
expect(transportDelete).toHaveBeenCalled();
937+
expect(mockDeleteFile).not.toHaveBeenCalled();
938+
});
939+
940+
it('throws when neither remoteStorage nor transportAdapter is provided', () => {
941+
expect(
942+
() =>
943+
new AttachmentQueue({
944+
db,
945+
watchAttachments,
946+
localStorage: mockLocalStorage,
947+
syncIntervalMs: INTERVAL_MILLISECONDS,
948+
archivedCacheLimit: 0
949+
})
950+
).toThrow(/remoteStorage/);
951+
});
881952
});
882953

883954
describe('attachment queue - saveFileFromUri', () => {

0 commit comments

Comments
 (0)