From f6506158cd7247b4198a76702044346fbb65c669 Mon Sep 17 00:00:00 2001 From: haru0017 Date: Mon, 1 Jun 2026 15:04:18 +0900 Subject: [PATCH 1/2] add downloadStream and uploadStream to file api --- .changeset/tangy-views-tie.md | 5 ++ packages/sdk/docs/runtime.md | 2 +- packages/sdk/docs/testing.md | 22 ++++++++- packages/sdk/src/runtime/file.test.ts | 47 ++++++++++++++++++ packages/sdk/src/runtime/file.ts | 68 ++++++++++++++++++++++++++- packages/sdk/src/vitest/mock.ts | 30 ++++++++++++ 6 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 .changeset/tangy-views-tie.md diff --git a/.changeset/tangy-views-tie.md b/.changeset/tangy-views-tie.md new file mode 100644 index 0000000000..3aa0a3717a --- /dev/null +++ b/.changeset/tangy-views-tie.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": minor +--- + +Add downloadStream and uploadStream to file api. Mark openDownloadStream as deprecated. diff --git a/packages/sdk/docs/runtime.md b/packages/sdk/docs/runtime.md index 0d3e316797..52a33b3fb7 100644 --- a/packages/sdk/docs/runtime.md +++ b/packages/sdk/docs/runtime.md @@ -78,7 +78,7 @@ The runtime entry re-exports the following namespaces. Detailed signatures, para - `idp` — IdP user management (`new Client({ namespace })`) - `workflow` — workflow & job control (`triggerWorkflow`, `triggerJobFunction`, `wait`, `resolve`) - `context` — execution context (`getInvoker`) -- `file` — `tailordb.file` BLOB API (`upload`, `download`, `downloadAsBase64`, `delete`, `getMetadata`, `openDownloadStream`) +- `file` — `tailordb.file` BLOB API (`upload`, `download`, `downloadAsBase64`, `delete`, `getMetadata`, `downloadStream`, `uploadStream`, `openDownloadStream` _(deprecated)_) ## Testing diff --git a/packages/sdk/docs/testing.md b/packages/sdk/docs/testing.md index 27b4a735e2..da0ac32167 100644 --- a/packages/sdk/docs/testing.md +++ b/packages/sdk/docs/testing.md @@ -227,10 +227,30 @@ test("mock file download", async () => { }); ``` -For `openDownloadStream`, enqueue an iterable of `StreamValue` items — `metadata`, one or more `chunk` items, and a terminal `complete`. Raw `Uint8Array` / `ArrayBuffer` chunks are rejected so tests stay aligned with the platform's structured stream contract. +For `downloadStream`, enqueue a `FileDownloadStreamResponse` object with a `ReadableStream` body and metadata: ```typescript test("mock file download stream", async () => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])); + controller.close(); + }, + }); + fileMock.enqueueResult({ + body, + metadata: { contentType: "image/png", fileSize: 3, sha256sum: "abc", lastUploadedAt: "" }, + }); + + const result = await tailordb.file.downloadStream("ns", "Doc", "attachment", "r-1"); + expect(result.metadata.fileSize).toBe(3); +}); +``` + +For the deprecated `openDownloadStream`, enqueue an iterable of `StreamValue` items — `metadata`, one or more `chunk` items, and a terminal `complete`. Raw `Uint8Array` / `ArrayBuffer` chunks are rejected so tests stay aligned with the platform's structured stream contract. + +```typescript +test("mock file download stream (deprecated openDownloadStream)", async () => { fileMock.enqueueResult([ { type: "metadata", diff --git a/packages/sdk/src/runtime/file.test.ts b/packages/sdk/src/runtime/file.test.ts index 3ec7acf528..b11c34c509 100644 --- a/packages/sdk/src/runtime/file.test.ts +++ b/packages/sdk/src/runtime/file.test.ts @@ -117,6 +117,53 @@ describe("@tailor-platform/sdk/runtime/file", () => { expect(fileMock.calls[0]?.method).toBe("openDownloadStream"); }); + test("downloadStream forwards and returns body with metadata", async () => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])); + controller.close(); + }, + }); + fileMock.enqueueResult({ + body, + metadata: { + contentType: "application/octet-stream", + fileSize: 3, + sha256sum: "h", + lastUploadedAt: "2026-01-01T00:00:00Z", + }, + }); + + const result = await file.downloadStream("ns", "Doc", "blob", "rec-1"); + + expect(result.body).toBe(body); + expect(result.metadata.fileSize).toBe(3); + expect(fileMock.calls[0]?.method).toBe("downloadStream"); + }); + + test("uploadStream forwards args and records the call", async () => { + fileMock.enqueueResult({ metadata: { fileSize: 10, sha256sum: "xyz" } }); + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])); + controller.close(); + }, + }); + const result = await file.uploadStream("ns", "Doc", "blob", "rec-1", stream); + + expect(result).toEqual({ metadata: { fileSize: 10, sha256sum: "xyz" } }); + expect(fileMock.calls).toEqual([ + { + method: "uploadStream", + namespace: "ns", + typeName: "Doc", + fieldName: "blob", + recordId: "rec-1", + }, + ]); + }); + test("TailorDBFileError structurally matches globalThis class", () => { const TailorDBFileError = ( globalThis as unknown as { diff --git a/packages/sdk/src/runtime/file.ts b/packages/sdk/src/runtime/file.ts index fe7ebfcada..e10031883a 100644 --- a/packages/sdk/src/runtime/file.ts +++ b/packages/sdk/src/runtime/file.ts @@ -51,6 +51,12 @@ export interface FileUploadOptions { contentType?: string; } +/** Upload stream options. */ +export interface FileUploadStreamOptions { + contentType?: string; + fileSize?: number; +} + /** Upload response. */ export interface FileUploadResponse { metadata: UploadMetadata; @@ -68,6 +74,12 @@ export interface FileDownloadAsBase64Response { metadata: DownloadMetadata; } +/** Download stream response. */ +export interface FileDownloadStreamResponse { + body: ReadableStream; + metadata: DownloadMetadata; +} + /** Stream chunk types emitted by {@link FileStreamIterator}. */ export type StreamValue = | { type: "metadata"; metadata: StreamMetadata } @@ -136,7 +148,7 @@ export interface TailorDBFileAPI { * Download a file from TailorDB. * * Throws `TailorDBFileError` with code `FILE_TOO_LARGE` when the file - * exceeds 10MB — use {@link openDownloadStream} for large files. + * exceeds 10MB — use {@link downloadStream} for large files. * @param namespace - TailorDB namespace * @param typeName - TailorDB type name * @param fieldName - File field name on the type @@ -154,7 +166,7 @@ export interface TailorDBFileAPI { * Download a file from TailorDB as a Base64-encoded string. * * Throws `TailorDBFileError` with code `FILE_TOO_LARGE` when the file - * exceeds 10MB — use {@link openDownloadStream} for large files. + * exceeds 10MB — use {@link downloadStream} for large files. * @param namespace - TailorDB namespace * @param typeName - TailorDB type name * @param fieldName - File field name on the type @@ -196,6 +208,7 @@ export interface TailorDBFileAPI { /** * Open a download stream for large files. + * @deprecated Use {@link downloadStream} instead. * @param namespace - TailorDB namespace * @param typeName - TailorDB type name * @param fieldName - File field name on the type @@ -208,6 +221,40 @@ export interface TailorDBFileAPI { fieldName: string, recordId: string, ): Promise; + + /** + * Download a file as a ReadableStream. + * @param namespace - TailorDB namespace + * @param typeName - TailorDB type name + * @param fieldName - File field name on the type + * @param recordId - Record ID owning the field + * @returns ReadableStream body and metadata for the file + */ + downloadStream( + namespace: string, + typeName: string, + fieldName: string, + recordId: string, + ): Promise; + + /** + * Upload a file using a ReadableStream. + * @param namespace - TailorDB namespace + * @param typeName - TailorDB type name + * @param fieldName - File field name on the type + * @param recordId - Record ID owning the field + * @param readableStream - ReadableStream providing the file data + * @param options - Upload stream options (e.g. `contentType`, `fileSize`) + * @returns Upload response containing the file metadata + */ + uploadStream( + namespace: string, + typeName: string, + fieldName: string, + recordId: string, + readableStream: ReadableStream, + options?: FileUploadStreamOptions, + ): Promise; } const api = (): TailorDBFileAPI => @@ -251,10 +298,27 @@ export const getMetadata: TailorDBFileAPI["getMetadata"] = (...args) => api().ge /** * See {@link TailorDBFileAPI.openDownloadStream}. + * @deprecated Use {@link downloadStream} instead. * @param args - Forwarded to {@link TailorDBFileAPI.openDownloadStream} * @returns Async iterator yielding file chunks; call `close()` to release resources */ export const openDownloadStream: TailorDBFileAPI["openDownloadStream"] = (...args) => api().openDownloadStream(...args); +/** + * See {@link TailorDBFileAPI.downloadStream}. + * @param args - Forwarded to {@link TailorDBFileAPI.downloadStream} + * @returns ReadableStream body and metadata for the file + */ +export const downloadStream: TailorDBFileAPI["downloadStream"] = (...args) => + api().downloadStream(...args); + +/** + * See {@link TailorDBFileAPI.uploadStream}. + * @param args - Forwarded to {@link TailorDBFileAPI.uploadStream} + * @returns Upload response containing the file metadata + */ +export const uploadStream: TailorDBFileAPI["uploadStream"] = (...args) => + api().uploadStream(...args); + export { deleteFile as delete }; diff --git a/packages/sdk/src/vitest/mock.ts b/packages/sdk/src/vitest/mock.ts index 8bbe36786a..6fcfdbcce1 100644 --- a/packages/sdk/src/vitest/mock.ts +++ b/packages/sdk/src/vitest/mock.ts @@ -922,6 +922,11 @@ const FILE_DEFAULTS: Record = { }, delete: undefined, getMetadata: { contentType: "", fileSize: 0, sha256sum: "", urlPath: "" }, + downloadStream: { + body: new ReadableStream(), + metadata: { contentType: "", fileSize: 0, sha256sum: "", lastUploadedAt: "" }, + }, + uploadStream: { metadata: { fileSize: 0, sha256sum: "" } }, }; function resolveFileCall( @@ -1024,6 +1029,31 @@ const mockTailordbFile = { ); return toFileStream(resolved); }, + async downloadStream( + namespace: string, + typeName: string, + fieldName: string, + recordId: string, + ): Promise<{ + body: ReadableStream; + metadata: { contentType: string; fileSize: number; sha256sum: string; lastUploadedAt: string }; + }> { + return resolveFileCall("downloadStream", namespace, typeName, fieldName, recordId) as Awaited< + ReturnType + >; + }, + async uploadStream( + namespace: string, + typeName: string, + fieldName: string, + recordId: string, + _readableStream: ReadableStream, + _options?: { contentType?: string; fileSize?: number }, + ): Promise<{ metadata: { fileSize: number; sha256sum: string } }> { + return resolveFileCall("uploadStream", namespace, typeName, fieldName, recordId) as Awaited< + ReturnType + >; + }, }; type FileStream = AsyncIterableIterator & { close(): Promise }; From ae0253055f5099c522e07a8a70fc9b31dc0d7cc3 Mon Sep 17 00:00:00 2001 From: haru0017 Date: Mon, 1 Jun 2026 15:38:23 +0900 Subject: [PATCH 2/2] fix: avoid structuredClone error and unclosed stream in downloadStream mock default --- packages/sdk/src/vitest/mock.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/sdk/src/vitest/mock.ts b/packages/sdk/src/vitest/mock.ts index 6fcfdbcce1..2c2c12d70e 100644 --- a/packages/sdk/src/vitest/mock.ts +++ b/packages/sdk/src/vitest/mock.ts @@ -922,10 +922,7 @@ const FILE_DEFAULTS: Record = { }, delete: undefined, getMetadata: { contentType: "", fileSize: 0, sha256sum: "", urlPath: "" }, - downloadStream: { - body: new ReadableStream(), - metadata: { contentType: "", fileSize: 0, sha256sum: "", lastUploadedAt: "" }, - }, + downloadStream: null, uploadStream: { metadata: { fileSize: 0, sha256sum: "" } }, }; @@ -1038,9 +1035,18 @@ const mockTailordbFile = { body: ReadableStream; metadata: { contentType: string; fileSize: number; sha256sum: string; lastUploadedAt: string }; }> { - return resolveFileCall("downloadStream", namespace, typeName, fieldName, recordId) as Awaited< - ReturnType - >; + const resolved = resolveFileCall("downloadStream", namespace, typeName, fieldName, recordId); + if (resolved != null) { + return resolved as Awaited>; + } + return { + body: new ReadableStream({ + start(c) { + c.close(); + }, + }), + metadata: { contentType: "", fileSize: 0, sha256sum: "", lastUploadedAt: "" }, + }; }, async uploadStream( namespace: string,