-
Notifications
You must be signed in to change notification settings - Fork 80
feat(attachments): streaming attachment transport + saveFileFromUri #1039
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
0c94d26
bcc9900
b5287d6
fcfa146
669caac
623883e
c39e1a7
037d1fc
cfe9d22
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| '@powersync/common': minor | ||
| '@powersync/attachments-storage-react-native': minor | ||
| '@powersync/node': minor | ||
| --- | ||
|
|
||
| Add a streaming attachment transport (`AttachmentTransportAdapter` with a default `BufferedAttachmentTransport`, plus native `ExpoFileSystemTransportAdapter` and `ReactNativeFSTransportAdapter` implementations) and `AttachmentQueue.saveFileFromUri`, so large files can be uploaded and downloaded without being buffered in JS memory. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| import type { AttachmentRecord, AttachmentTransportAdapter, LocatedAttachmentRecord } from '@powersync/common'; | ||
|
|
||
| /** | ||
| * Describes the HTTP request used to upload a file's bytes to remote storage. | ||
| * Typically points at a presigned URL. | ||
| */ | ||
| export interface ExpoUploadRequest { | ||
| /** Destination URL (e.g. a presigned upload URL). */ | ||
| url: string; | ||
| /** HTTP method. Defaults to `PUT`. */ | ||
| httpMethod?: 'POST' | 'PUT' | 'PATCH'; | ||
| /** Additional request headers. */ | ||
| headers?: Record<string, string>; | ||
| /** | ||
| * Upload encoding, matching Expo's `FileSystemUploadType` | ||
| * (`0` = binary content, `1` = multipart). Defaults to binary content. | ||
| */ | ||
| uploadType?: number; | ||
| /** Form field name, used only for multipart uploads. */ | ||
| fieldName?: string; | ||
| /** MIME type of the file. Defaults to the attachment's `mediaType`. */ | ||
| mimeType?: string; | ||
| } | ||
|
|
||
| /** | ||
| * Describes the HTTP request used to download a file's bytes from remote storage. | ||
| * Typically points at a presigned URL. | ||
| */ | ||
| export interface ExpoDownloadRequest { | ||
| /** Source URL (e.g. a presigned download URL). */ | ||
| url: string; | ||
| /** Additional request headers. */ | ||
| headers?: Record<string, string>; | ||
| } | ||
|
|
||
| /** | ||
| * Configuration for {@link ExpoFileSystemTransportAdapter}. | ||
| * | ||
| * The resolvers map an attachment to the request that transfers its bytes, keeping | ||
| * the transport agnostic of the remote storage backend (S3, Supabase, etc.). | ||
| */ | ||
| export interface ExpoFileSystemTransportAdapterOptions { | ||
| /** Resolves the upload request (e.g. a presigned URL) for an attachment. */ | ||
| resolveUpload: (attachment: LocatedAttachmentRecord) => Promise<ExpoUploadRequest> | ExpoUploadRequest; | ||
| /** Resolves the download request (e.g. a presigned URL) for an attachment. */ | ||
| resolveDownload: (attachment: LocatedAttachmentRecord) => Promise<ExpoDownloadRequest> | ExpoDownloadRequest; | ||
| /** | ||
| * Deletes the attachment's file from remote storage (e.g. a storage SDK call or a | ||
| * `DELETE` request). Delete is a plain remote operation, not a file transfer. | ||
| */ | ||
| deleteFile: (attachment: AttachmentRecord) => Promise<void>; | ||
| } | ||
|
|
||
| /** The `expo-file-system` legacy API surface this adapter resolves and uses at runtime. */ | ||
| type ExpoUploadResult = { status: number; body?: string }; | ||
| type ExpoDownloadResult = { status: number; uri: string }; | ||
|
|
||
| interface ExpoFileSystemLegacy { | ||
| uploadAsync(url: string, fileUri: string, options?: Record<string, unknown>): Promise<ExpoUploadResult>; | ||
| downloadAsync(uri: string, fileUri: string, options?: Record<string, unknown>): Promise<ExpoDownloadResult>; | ||
| FileSystemUploadType?: { BINARY_CONTENT: number; MULTIPART: number }; | ||
| } | ||
|
|
||
| /** Upload encodings, mirroring Expo's `FileSystemUploadType`. */ | ||
| const UPLOAD_TYPE_BINARY_CONTENT = 0; | ||
|
|
||
| /** | ||
| * ExpoFileSystemTransportAdapter transfers attachment bytes directly between a local | ||
| * file URI and remote storage using Expo's native `uploadAsync` / `downloadAsync`. | ||
| * | ||
| * The bytes never enter the JS heap, so large files can be transferred without the | ||
| * memory pressure of the buffer-based transport. Requires `expo-file-system` (its | ||
| * `uploadAsync` / `downloadAsync` functions, available via `expo-file-system/legacy` | ||
| * on SDK 54+). | ||
| * | ||
| * @experimental | ||
| * @alpha This is currently experimental and may change without a major version bump. | ||
| */ | ||
| export class ExpoFileSystemTransportAdapter implements AttachmentTransportAdapter { | ||
| private fs: ExpoFileSystemLegacy; | ||
|
|
||
| constructor(private options: ExpoFileSystemTransportAdapterOptions) { | ||
| // `uploadAsync` / `downloadAsync` live in `expo-file-system/legacy` on SDK 54+, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Given that this is a new functionality, I think we can also just make Expo 54+ a requirement for streaming transport. That would then also let us use modern Expo APIs ( |
||
| // and in the main module on older SDKs. Metro only bundles static `require` | ||
| // string literals, so each candidate is required explicitly. | ||
| let resolved: ExpoFileSystemLegacy | undefined; | ||
| try { | ||
| const legacy = require('expo-file-system/legacy'); | ||
| if (typeof legacy?.uploadAsync === 'function' && typeof legacy?.downloadAsync === 'function') { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What are we guarding against here, |
||
| resolved = legacy; | ||
| } | ||
| } catch { | ||
| // `/legacy` subpath not available on this SDK; fall back to the main module. | ||
| } | ||
| if (!resolved) { | ||
| try { | ||
| const main = require('expo-file-system'); | ||
| if (typeof main?.uploadAsync === 'function' && typeof main?.downloadAsync === 'function') { | ||
| resolved = main; | ||
| } | ||
| } catch { | ||
| // expo-file-system not installed. | ||
| } | ||
| } | ||
|
|
||
| if (!resolved) { | ||
| throw new Error(`Could not resolve expo-file-system's uploadAsync/downloadAsync. | ||
| To use the Expo File System transport please install expo-file-system.`); | ||
| } | ||
|
|
||
| this.fs = resolved; | ||
| } | ||
|
|
||
| async upload(attachment: LocatedAttachmentRecord): Promise<void> { | ||
| const request = await this.options.resolveUpload(attachment); | ||
| const binaryContent = this.fs.FileSystemUploadType?.BINARY_CONTENT ?? UPLOAD_TYPE_BINARY_CONTENT; | ||
|
|
||
| const result = await this.fs.uploadAsync(request.url, attachment.localUri, { | ||
| httpMethod: request.httpMethod ?? 'PUT', | ||
| uploadType: request.uploadType ?? binaryContent, | ||
| headers: request.headers, | ||
| fieldName: request.fieldName, | ||
| mimeType: request.mimeType ?? attachment.mediaType | ||
| }); | ||
|
|
||
| if (!this.isOk(result.status)) { | ||
| throw new Error(`Upload for ${attachment.id} failed with status ${result.status}: ${result.body ?? ''}`); | ||
| } | ||
| } | ||
|
|
||
| async download(attachment: LocatedAttachmentRecord): Promise<void> { | ||
| const request = await this.options.resolveDownload(attachment); | ||
|
|
||
| const result = await this.fs.downloadAsync(request.url, attachment.localUri, { | ||
| headers: request.headers | ||
| }); | ||
|
|
||
| if (!this.isOk(result.status)) { | ||
| throw new Error(`Download for ${attachment.id} failed with status ${result.status}`); | ||
| } | ||
| } | ||
|
|
||
| async delete(attachment: AttachmentRecord): Promise<void> { | ||
| await this.options.deleteFile(attachment); | ||
| } | ||
|
|
||
| private isOk(status: number): boolean { | ||
| return status >= 200 && status < 300; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it be possible to add an optional dependency on expo and then use expo types here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I could not import the types from Expo. The dependency is already present.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What errors did you get? We might need to tweak some typescript configs but copying the types doesn't sound right.