Skip to content

Commit 509d298

Browse files
khawarizmusclaude
andcommitted
feat(attachments): streaming transport + saveFileFromUri
Model attachment byte-transfer as a single operation via AttachmentTransportAdapter, with a default BufferedAttachmentTransport that composes the existing local/remote adapters. Add AttachmentQueue.saveFileFromUri and an optional LocalStorageAdapter.moveFile for buffer-free registration of files already on disk, a native Expo ExpoFileSystemTransportAdapter, and moveFile in the Node and React Native local adapters. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent efb8121 commit 509d298

13 files changed

Lines changed: 572 additions & 9 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@powersync/common': minor
3+
'@powersync/attachments-storage-react-native': minor
4+
'@powersync/node': minor
5+
---
6+
7+
Add a streaming attachment transport (`AttachmentTransportAdapter` with a default `BufferedAttachmentTransport`, plus a native Expo `ExpoFileSystemTransportAdapter`) and `AttachmentQueue.saveFileFromUri`, so large files can be uploaded and downloaded without being buffered in JS memory.

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,17 @@ To use the Expo File System attachment adapter please install expo-file-system (
8181
return buffer;
8282
}
8383

84+
async moveFile(sourceUri: string, targetUri: string): Promise<number> {
85+
if (sourceUri !== targetUri) {
86+
const target = new this.File(targetUri);
87+
if (target.exists) {
88+
target.delete();
89+
}
90+
new this.File(sourceUri).move(target);
91+
}
92+
return new this.File(targetUri).size ?? 0;
93+
}
94+
8495
async deleteFile(filePath: string): Promise<void> {
8596
try {
8697
const file = new this.File(filePath);
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import type { AttachmentTransportAdapter, LocatedAttachmentRecord } from '@powersync/common';
2+
3+
/**
4+
* Describes the HTTP request used to upload a file's bytes to remote storage.
5+
* Typically points at a presigned URL.
6+
*/
7+
export interface ExpoUploadRequest {
8+
/** Destination URL (e.g. a presigned upload URL). */
9+
url: string;
10+
/** HTTP method. Defaults to `PUT`. */
11+
httpMethod?: 'POST' | 'PUT' | 'PATCH';
12+
/** Additional request headers. */
13+
headers?: Record<string, string>;
14+
/**
15+
* Upload encoding, matching Expo's `FileSystemUploadType`
16+
* (`0` = binary content, `1` = multipart). Defaults to binary content.
17+
*/
18+
uploadType?: number;
19+
/** Form field name, used only for multipart uploads. */
20+
fieldName?: string;
21+
/** MIME type of the file. Defaults to the attachment's `mediaType`. */
22+
mimeType?: string;
23+
}
24+
25+
/**
26+
* Describes the HTTP request used to download a file's bytes from remote storage.
27+
* Typically points at a presigned URL.
28+
*/
29+
export interface ExpoDownloadRequest {
30+
/** Source URL (e.g. a presigned download URL). */
31+
url: string;
32+
/** Additional request headers. */
33+
headers?: Record<string, string>;
34+
}
35+
36+
/**
37+
* Configuration for {@link ExpoFileSystemTransportAdapter}.
38+
*
39+
* The resolvers map an attachment to the request that transfers its bytes, keeping
40+
* the transport agnostic of the remote storage backend (S3, Supabase, etc.).
41+
*/
42+
export interface ExpoFileSystemTransportAdapterOptions {
43+
/** Resolves the upload request (e.g. a presigned URL) for an attachment. */
44+
resolveUpload: (attachment: LocatedAttachmentRecord) => Promise<ExpoUploadRequest> | ExpoUploadRequest;
45+
/** Resolves the download request (e.g. a presigned URL) for an attachment. */
46+
resolveDownload: (attachment: LocatedAttachmentRecord) => Promise<ExpoDownloadRequest> | ExpoDownloadRequest;
47+
}
48+
49+
type ExpoUploadResult = { status: number; body?: string };
50+
type ExpoDownloadResult = { status: number; uri: string };
51+
52+
interface ExpoFileSystemLegacy {
53+
uploadAsync(url: string, fileUri: string, options?: Record<string, unknown>): Promise<ExpoUploadResult>;
54+
downloadAsync(uri: string, fileUri: string, options?: Record<string, unknown>): Promise<ExpoDownloadResult>;
55+
FileSystemUploadType?: { BINARY_CONTENT: number; MULTIPART: number };
56+
}
57+
58+
/** Upload encodings, mirroring Expo's `FileSystemUploadType`. */
59+
const UPLOAD_TYPE_BINARY_CONTENT = 0;
60+
61+
/**
62+
* ExpoFileSystemTransportAdapter transfers attachment bytes directly between a local
63+
* file URI and remote storage using Expo's native `uploadAsync` / `downloadAsync`.
64+
*
65+
* The bytes never enter the JS heap, so large files can be transferred without the
66+
* memory pressure of the buffer-based transport. Requires `expo-file-system` (its
67+
* `uploadAsync` / `downloadAsync` functions, available via `expo-file-system/legacy`
68+
* on SDK 54+).
69+
*
70+
* @experimental
71+
* @alpha This is currently experimental and may change without a major version bump.
72+
*/
73+
export class ExpoFileSystemTransportAdapter implements AttachmentTransportAdapter {
74+
private fs: ExpoFileSystemLegacy;
75+
76+
constructor(private options: ExpoFileSystemTransportAdapterOptions) {
77+
// `uploadAsync` / `downloadAsync` live in `expo-file-system/legacy` on SDK 54+,
78+
// and in the main module on older SDKs. Metro only bundles static `require`
79+
// string literals, so each candidate is required explicitly.
80+
let resolved: ExpoFileSystemLegacy | undefined;
81+
try {
82+
const legacy = require('expo-file-system/legacy');
83+
if (typeof legacy?.uploadAsync === 'function' && typeof legacy?.downloadAsync === 'function') {
84+
resolved = legacy;
85+
}
86+
} catch {
87+
// `/legacy` subpath not available on this SDK; fall back to the main module.
88+
}
89+
if (!resolved) {
90+
try {
91+
const main = require('expo-file-system');
92+
if (typeof main?.uploadAsync === 'function' && typeof main?.downloadAsync === 'function') {
93+
resolved = main;
94+
}
95+
} catch {
96+
// expo-file-system not installed.
97+
}
98+
}
99+
100+
if (!resolved) {
101+
throw new Error(`Could not resolve expo-file-system's uploadAsync/downloadAsync.
102+
To use the Expo File System transport please install expo-file-system.`);
103+
}
104+
105+
this.fs = resolved;
106+
}
107+
108+
async upload(attachment: LocatedAttachmentRecord): Promise<void> {
109+
const request = await this.options.resolveUpload(attachment);
110+
const binaryContent = this.fs.FileSystemUploadType?.BINARY_CONTENT ?? UPLOAD_TYPE_BINARY_CONTENT;
111+
112+
const result = await this.fs.uploadAsync(request.url, attachment.localUri, {
113+
httpMethod: request.httpMethod ?? 'PUT',
114+
uploadType: request.uploadType ?? binaryContent,
115+
headers: request.headers,
116+
fieldName: request.fieldName,
117+
mimeType: request.mimeType ?? attachment.mediaType
118+
});
119+
120+
if (!this.isOk(result.status)) {
121+
throw new Error(`Upload for ${attachment.id} failed with status ${result.status}: ${result.body ?? ''}`);
122+
}
123+
}
124+
125+
async download(attachment: LocatedAttachmentRecord): Promise<void> {
126+
const request = await this.options.resolveDownload(attachment);
127+
128+
const result = await this.fs.downloadAsync(request.url, attachment.localUri, {
129+
headers: request.headers
130+
});
131+
132+
if (!this.isOk(result.status)) {
133+
throw new Error(`Download for ${attachment.id} failed with status ${result.status}`);
134+
}
135+
}
136+
137+
private isOk(status: number): boolean {
138+
return status >= 200 && status < 300;
139+
}
140+
}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,17 @@ To use the React Native File System attachment adapter please install @dr.pogodi
7171
return decodeBase64(content);
7272
}
7373

74+
async moveFile(sourceUri: string, targetUri: string): Promise<number> {
75+
if (sourceUri !== targetUri) {
76+
if (await this.rnfs.exists(targetUri)) {
77+
await this.rnfs.unlink(targetUri);
78+
}
79+
await this.rnfs.moveFile(sourceUri, targetUri);
80+
}
81+
const stat = await this.rnfs.stat(targetUri);
82+
return Number(stat.size);
83+
}
84+
7485
async deleteFile(filePath: string): Promise<void> {
7586
try {
7687
await this.rnfs.unlink(filePath);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './ExpoFileSystemStorageAdapter.js';
2+
export * from './ExpoFileSystemTransportAdapter.js';
23
export * from './ReactNativeFileSystemStorageAdapter.js';

packages/common/src/attachments/AttachmentQueue.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { Transaction } from '../db/DBAdapter.js';
55
import { AttachmentContext } from './AttachmentContext.js';
66
import { AttachmentErrorHandler } from './AttachmentErrorHandler.js';
77
import { AttachmentService } from './AttachmentService.js';
8+
import { AttachmentTransportAdapter } from './AttachmentTransportAdapter.js';
9+
import { BufferedAttachmentTransport } from './BufferedAttachmentTransport.js';
810
import { AttachmentData, LocalStorageAdapter } from './LocalStorageAdapter.js';
911
import { RemoteStorageAdapter } from './RemoteStorageAdapter.js';
1012
import { ATTACHMENT_TABLE, AttachmentRecord, AttachmentState } from './Schema.js';
@@ -31,6 +33,13 @@ export interface AttachmentQueueOptions {
3133
* Local storage adapter for file persistence
3234
*/
3335
localStorage: LocalStorageAdapter;
36+
/**
37+
* Transport responsible for moving attachment bytes between local and remote storage.
38+
* Defaults to a {@link BufferedAttachmentTransport} composed from `localStorage` and
39+
* `remoteStorage`. Provide a custom transport (e.g. a native file-URI implementation)
40+
* to transfer large files without materializing them in JS memory.
41+
*/
42+
transportAdapter?: AttachmentTransportAdapter;
3443
/**
3544
* Callback for monitoring attachment changes in your data model
3645
*/
@@ -159,6 +168,7 @@ export class AttachmentQueue {
159168
db,
160169
localStorage,
161170
remoteStorage,
171+
transportAdapter,
162172
watchAttachments,
163173
logger,
164174
tableName = ATTACHMENT_TABLE,
@@ -184,6 +194,7 @@ export class AttachmentQueue {
184194
this.attachmentService,
185195
localStorage,
186196
remoteStorage,
197+
transportAdapter ?? new BufferedAttachmentTransport(localStorage, remoteStorage),
187198
this.logger,
188199
errorHandler
189200
);
@@ -474,6 +485,83 @@ export class AttachmentQueue {
474485
return attachment;
475486
}
476487

488+
/**
489+
* Registers a file that already exists on disk and queues it for upload, moving it
490+
* into managed storage without loading it into memory.
491+
*
492+
* Prefer this over {@link saveFile} for large, app-originated files (recordings,
493+
* videos): it avoids reading the file into an `ArrayBuffer` just to write it back to
494+
* disk. Requires the local storage adapter to implement `moveFile`.
495+
*
496+
* @param options - File registration options
497+
* @returns Promise resolving to the created attachment record
498+
* @throws Error if the local storage adapter does not support moving files
499+
*/
500+
async saveFileFromUri({
501+
localUri,
502+
fileExtension,
503+
mediaType,
504+
metaData,
505+
id,
506+
updateHook
507+
}: {
508+
/**
509+
* Path to the existing file on disk
510+
*/
511+
localUri: string;
512+
/**
513+
* File extension (e.g., 'jpg', 'pdf')
514+
*/
515+
fileExtension: string;
516+
/**
517+
* MIME type of the file (e.g., 'image/jpeg')
518+
*/
519+
mediaType?: string;
520+
/**
521+
* Optional metadata to associate with the attachment
522+
*/
523+
metaData?: string;
524+
/**
525+
* Optional custom ID. If not provided, a UUID will be generated
526+
*/
527+
id?: string;
528+
/**
529+
* Optional callback to execute additional database operations within the same transaction as the attachment
530+
* creation.
531+
*/
532+
updateHook?: (transaction: Transaction, attachment: AttachmentRecord) => Promise<void>;
533+
}): Promise<AttachmentRecord> {
534+
if (!this.localStorage.moveFile) {
535+
throw new Error('The configured local storage adapter does not support moveFile, required by saveFileFromUri.');
536+
}
537+
538+
const resolvedId = id ?? (await this.generateAttachmentId());
539+
const filename = `${resolvedId}.${fileExtension}`;
540+
const targetUri = this.localStorage.getLocalUri(filename);
541+
const size = await this.localStorage.moveFile(localUri, targetUri);
542+
543+
const attachment: AttachmentRecord = {
544+
id: resolvedId,
545+
filename,
546+
mediaType,
547+
localUri: targetUri,
548+
state: AttachmentState.QUEUED_UPLOAD,
549+
hasSynced: false,
550+
size,
551+
timestamp: new Date().getTime(),
552+
metaData
553+
};
554+
555+
await this.attachmentService.withContext(async (ctx) => {
556+
await ctx.db.writeTransaction(async (tx) => {
557+
await updateHook?.(tx, attachment);
558+
await ctx.upsertAttachment(attachment, tx);
559+
});
560+
});
561+
562+
return attachment;
563+
}
564+
477565
async deleteFile({
478566
id,
479567
updateHook
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { AttachmentRecord } from './Schema.js';
2+
3+
/**
4+
* An {@link AttachmentRecord} that is guaranteed to have a `localUri`.
5+
*
6+
* The syncing service assigns `localUri` before invoking a transport download,
7+
* so implementations always receive both the metadata and the destination path.
8+
*/
9+
export type LocatedAttachmentRecord = AttachmentRecord & { localUri: string };
10+
11+
/**
12+
* AttachmentTransportAdapter models the movement of an attachment's bytes between
13+
* local storage and remote storage as a single operation.
14+
*
15+
* A transport owns the entire transfer, letting implementations pick the most
16+
* efficient mechanism available (buffer, stream, or a platform-native file-URI
17+
* upload/download API). On platforms like React Native this allows large files to
18+
* be transferred without ever materializing them in the JS heap.
19+
*
20+
* {@link BufferedAttachmentTransport} is the default, composing the local and
21+
* remote storage adapters.
22+
*
23+
* @experimental
24+
* @alpha This is currently experimental and may change without a major version bump.
25+
*/
26+
export interface AttachmentTransportAdapter {
27+
/**
28+
* Uploads the attachment's local file to remote storage.
29+
* @param attachment - The attachment to upload. `localUri` points at the source file.
30+
*/
31+
upload(attachment: LocatedAttachmentRecord): Promise<void>;
32+
33+
/**
34+
* Downloads the remote file into `attachment.localUri`.
35+
* @param attachment - The attachment to download. `localUri` is the destination path,
36+
* assigned by the syncing service before this call.
37+
*/
38+
download(attachment: LocatedAttachmentRecord): Promise<void>;
39+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { AttachmentTransportAdapter, LocatedAttachmentRecord } from './AttachmentTransportAdapter.js';
2+
import { LocalStorageAdapter } from './LocalStorageAdapter.js';
3+
import { RemoteStorageAdapter } from './RemoteStorageAdapter.js';
4+
5+
/**
6+
* Default {@link AttachmentTransportAdapter}, composing the local and remote
7+
* storage adapters.
8+
*
9+
* The full file body is materialized as an `ArrayBuffer` in JS memory between the
10+
* two adapter calls. This is fine for small files but can cause memory pressure on
11+
* large ones — environments that support native transfer should provide a custom
12+
* transport instead.
13+
*
14+
* @experimental
15+
* @alpha This is currently experimental and may change without a major version bump.
16+
*/
17+
export class BufferedAttachmentTransport implements AttachmentTransportAdapter {
18+
constructor(
19+
private localStorage: LocalStorageAdapter,
20+
private remoteStorage: RemoteStorageAdapter
21+
) {}
22+
23+
async upload(attachment: LocatedAttachmentRecord): Promise<void> {
24+
const fileData = await this.localStorage.readFile(attachment.localUri);
25+
await this.remoteStorage.uploadFile(fileData, attachment);
26+
}
27+
28+
async download(attachment: LocatedAttachmentRecord): Promise<void> {
29+
const fileData = await this.remoteStorage.downloadFile(attachment);
30+
await this.localStorage.saveFile(attachment.localUri, fileData);
31+
}
32+
}

0 commit comments

Comments
 (0)