Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tangy-views-tie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tailor-platform/sdk": minor
---

Add downloadStream and uploadStream to file api. Mark openDownloadStream as deprecated.
2 changes: 1 addition & 1 deletion packages/sdk/docs/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 21 additions & 1 deletion packages/sdk/docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
47 changes: 47 additions & 0 deletions packages/sdk/src/runtime/file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
68 changes: 66 additions & 2 deletions packages/sdk/src/runtime/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -68,6 +74,12 @@ export interface FileDownloadAsBase64Response {
metadata: DownloadMetadata;
}

/** Download stream response. */
export interface FileDownloadStreamResponse {
body: ReadableStream<Uint8Array>;
metadata: DownloadMetadata;
}

/** Stream chunk types emitted by {@link FileStreamIterator}. */
export type StreamValue =
| { type: "metadata"; metadata: StreamMetadata }
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -208,6 +221,40 @@ export interface TailorDBFileAPI {
fieldName: string,
recordId: string,
): Promise<FileStreamIterator>;

/**
* 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<FileDownloadStreamResponse>;

/**
* 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<Uint8Array | ArrayBuffer>,
options?: FileUploadStreamOptions,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[q] I do not quite understand the significance of this being optional.
What is the difference between when it is specified and when it is not?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both contentType and fileSize have default values, so callers don't need to specify them unless they want to override.

): Promise<FileUploadResponse>;
}

const api = (): TailorDBFileAPI =>
Expand Down Expand Up @@ -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 };
36 changes: 36 additions & 0 deletions packages/sdk/src/vitest/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,8 @@ const FILE_DEFAULTS: Record<string, any> = {
},
delete: undefined,
getMetadata: { contentType: "", fileSize: 0, sha256sum: "", urlPath: "" },
downloadStream: null,
uploadStream: { metadata: { fileSize: 0, sha256sum: "" } },
};

function resolveFileCall(
Expand Down Expand Up @@ -1024,6 +1026,40 @@ const mockTailordbFile = {
);
return toFileStream(resolved);
},
async downloadStream(
namespace: string,
typeName: string,
fieldName: string,
recordId: string,
): Promise<{
body: ReadableStream<Uint8Array>;
metadata: { contentType: string; fileSize: number; sha256sum: string; lastUploadedAt: string };
}> {
const resolved = resolveFileCall("downloadStream", namespace, typeName, fieldName, recordId);
if (resolved != null) {
return resolved as Awaited<ReturnType<typeof this.downloadStream>>;
}
return {
body: new ReadableStream({
start(c) {
c.close();
},
}),
metadata: { contentType: "", fileSize: 0, sha256sum: "", lastUploadedAt: "" },
};
},
Comment thread
haru0017 marked this conversation as resolved.
Comment thread
haru0017 marked this conversation as resolved.
async uploadStream(
namespace: string,
typeName: string,
fieldName: string,
recordId: string,
_readableStream: ReadableStream<Uint8Array | ArrayBuffer>,
_options?: { contentType?: string; fileSize?: number },
): Promise<{ metadata: { fileSize: number; sha256sum: string } }> {
return resolveFileCall("uploadStream", namespace, typeName, fieldName, recordId) as Awaited<
ReturnType<typeof this.uploadStream>
>;
},
};

type FileStream = AsyncIterableIterator<unknown> & { close(): Promise<void> };
Expand Down
Loading