diff --git a/.changeset/runtime-wrapper.md b/.changeset/runtime-wrapper.md new file mode 100644 index 000000000..a8a176fb8 --- /dev/null +++ b/.changeset/runtime-wrapper.md @@ -0,0 +1,22 @@ +--- +"@tailor-platform/sdk": minor +--- + +Add `@tailor-platform/sdk/runtime` — typed wrappers for the Tailor Platform Function runtime APIs (`tailor.iconv`, `tailor.secretmanager`, `tailor.authconnection`, `tailor.idp`, `tailor.workflow`, `tailor.context`, and `tailordb.file`). The wrappers and their types are fully self-contained, so you can use them without activating any ambient globals. + +```ts +import { iconv, secretmanager, idp, file } from "@tailor-platform/sdk/runtime"; + +const utf8 = iconv.convert(sjisBuffer, "Shift_JIS", "UTF-8"); +const apiKey = await secretmanager.getSecret("my-vault", "API_KEY"); +const client = new idp.Client({ namespace: "my-namespace" }); +const { metadata } = await file.upload("ns", "Document", "attachment", recordId, bytes); +``` + +The SDK no longer depends on the external `@tailor-platform/function-types` package; its declarations are now vendored inside the SDK. For backwards compatibility the ambient `tailor.*` / `tailordb.*` types are still activated automatically when you import from `@tailor-platform/sdk`, so existing code keeps type-checking with no changes. This implicit activation will be removed in v2.0 — new code is encouraged to use the typed wrappers from `@tailor-platform/sdk/runtime`, or to opt into the globals explicitly via `import "@tailor-platform/sdk/runtime/globals"` (or by listing the entry in `tsconfig.json`'s `compilerOptions.types`). + +The capital-cased `Tailordb` ambient namespace (`Tailordb.QueryResult`, `Tailordb.CommandType`, `Tailordb.Client`) is preserved as a `@deprecated` alias of the new lowercase `tailordb.*` namespace for source-level compatibility with `@tailor-platform/function-types`. It will be removed in v2.0; run `pnpm dlx @tailor-platform/sdk-codemod v2/tailordb-namespace` to migrate. The `@tailor-platform/function-types` declarations are vendored inside the SDK and activated automatically, so you can simply remove `@tailor-platform/function-types` from your `package.json` (and from `tsconfig.json` `compilerOptions.types` if listed) once you've upgraded. + +Other test-mock changes from `@tailor-platform/sdk/vitest`: + +- Breaking: when an `openDownloadStream` (or `toFileStream()`) call consumes a queued mock result, raw `Uint8Array` / `ArrayBuffer` payloads are now rejected. Enqueue a structured iterable of `StreamValue` items (`{ type: "metadata" }`, `{ type: "chunk", data, position }`, `{ type: "complete" }`) so test streams stay aligned with the platform's structured stream contract. The shorthand `Uint8Array` enqueue is still accepted by `download` / `downloadAsBase64`. diff --git a/.changeset/sdk-codemod-tailordb-namespace.md b/.changeset/sdk-codemod-tailordb-namespace.md new file mode 100644 index 000000000..2e28c6e79 --- /dev/null +++ b/.changeset/sdk-codemod-tailordb-namespace.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk-codemod": patch +--- + +Add `v2/tailordb-namespace` codemod for the `@tailor-platform/function-types` → `@tailor-platform/sdk` vendoring: rewrite references to the deprecated capital-cased `Tailordb` ambient namespace (`Tailordb.QueryResult`, `Tailordb.CommandType`, `Tailordb.Client`, `typeof Tailordb.Client`) to the new lowercase `tailordb.*` namespace re-published by the SDK. diff --git a/example/generated/files.ts b/example/generated/files.ts index 052806afd..0389b9acd 100644 --- a/example/generated/files.ts +++ b/example/generated/files.ts @@ -1,3 +1,11 @@ +import * as file from "@tailor-platform/sdk/runtime/file"; +import type { + FileUploadOptions, + FileUploadResponse, + FileMetadata, + FileStreamIterator, +} from "@tailor-platform/sdk/runtime/file"; + export interface TypeWithFiles { SalesOrder: { fields: "receipt" | "form"; @@ -21,7 +29,7 @@ export async function downloadFile( field: TypeWithFiles[T]["fields"], recordId: string, ) { - return await tailordb.file.download(namespaces[type], type, field, recordId); + return await file.download(namespaces[type], type, field, recordId); } export async function uploadFile( @@ -31,7 +39,7 @@ export async function uploadFile( data: string | ArrayBuffer | Uint8Array | number[], options?: FileUploadOptions, ): Promise { - return await tailordb.file.upload(namespaces[type], type, field, recordId, data, options); + return await file.upload(namespaces[type], type, field, recordId, data, options); } export async function deleteFile( @@ -39,7 +47,7 @@ export async function deleteFile( field: TypeWithFiles[T]["fields"], recordId: string, ): Promise { - return await tailordb.file.delete(namespaces[type], type, field, recordId); + return await file.delete(namespaces[type], type, field, recordId); } export async function getFileMetadata( @@ -47,7 +55,7 @@ export async function getFileMetadata( field: TypeWithFiles[T]["fields"], recordId: string, ): Promise { - return await tailordb.file.getMetadata(namespaces[type], type, field, recordId); + return await file.getMetadata(namespaces[type], type, field, recordId); } export async function openFileDownloadStream( @@ -55,5 +63,5 @@ export async function openFileDownloadStream( field: TypeWithFiles[T]["fields"], recordId: string, ): Promise { - return await tailordb.file.openDownloadStream(namespaces[type], type, field, recordId); + return await file.openDownloadStream(namespaces[type], type, field, recordId); } diff --git a/example/tests/fixtures/expected/files.ts b/example/tests/fixtures/expected/files.ts index 052806afd..0389b9acd 100644 --- a/example/tests/fixtures/expected/files.ts +++ b/example/tests/fixtures/expected/files.ts @@ -1,3 +1,11 @@ +import * as file from "@tailor-platform/sdk/runtime/file"; +import type { + FileUploadOptions, + FileUploadResponse, + FileMetadata, + FileStreamIterator, +} from "@tailor-platform/sdk/runtime/file"; + export interface TypeWithFiles { SalesOrder: { fields: "receipt" | "form"; @@ -21,7 +29,7 @@ export async function downloadFile( field: TypeWithFiles[T]["fields"], recordId: string, ) { - return await tailordb.file.download(namespaces[type], type, field, recordId); + return await file.download(namespaces[type], type, field, recordId); } export async function uploadFile( @@ -31,7 +39,7 @@ export async function uploadFile( data: string | ArrayBuffer | Uint8Array | number[], options?: FileUploadOptions, ): Promise { - return await tailordb.file.upload(namespaces[type], type, field, recordId, data, options); + return await file.upload(namespaces[type], type, field, recordId, data, options); } export async function deleteFile( @@ -39,7 +47,7 @@ export async function deleteFile( field: TypeWithFiles[T]["fields"], recordId: string, ): Promise { - return await tailordb.file.delete(namespaces[type], type, field, recordId); + return await file.delete(namespaces[type], type, field, recordId); } export async function getFileMetadata( @@ -47,7 +55,7 @@ export async function getFileMetadata( field: TypeWithFiles[T]["fields"], recordId: string, ): Promise { - return await tailordb.file.getMetadata(namespaces[type], type, field, recordId); + return await file.getMetadata(namespaces[type], type, field, recordId); } export async function openFileDownloadStream( @@ -55,5 +63,5 @@ export async function openFileDownloadStream( field: TypeWithFiles[T]["fields"], recordId: string, ): Promise { - return await tailordb.file.openDownloadStream(namespaces[type], type, field, recordId); + return await file.openDownloadStream(namespaces[type], type, field, recordId); } diff --git a/packages/create-sdk/templates/generators/src/generated/files.ts b/packages/create-sdk/templates/generators/src/generated/files.ts index 8bff0ff81..e7498a317 100644 --- a/packages/create-sdk/templates/generators/src/generated/files.ts +++ b/packages/create-sdk/templates/generators/src/generated/files.ts @@ -1,3 +1,11 @@ +import * as file from "@tailor-platform/sdk/runtime/file"; +import type { + FileUploadOptions, + FileUploadResponse, + FileMetadata, + FileStreamIterator, +} from "@tailor-platform/sdk/runtime/file"; + export interface TypeWithFiles { Product: { fields: "image"; @@ -13,7 +21,7 @@ export async function downloadFile( field: TypeWithFiles[T]["fields"], recordId: string, ) { - return await tailordb.file.download(namespaces[type], type, field, recordId); + return await file.download(namespaces[type], type, field, recordId); } export async function uploadFile( @@ -23,7 +31,7 @@ export async function uploadFile( data: string | ArrayBuffer | Uint8Array | number[], options?: FileUploadOptions, ): Promise { - return await tailordb.file.upload(namespaces[type], type, field, recordId, data, options); + return await file.upload(namespaces[type], type, field, recordId, data, options); } export async function deleteFile( @@ -31,7 +39,7 @@ export async function deleteFile( field: TypeWithFiles[T]["fields"], recordId: string, ): Promise { - return await tailordb.file.delete(namespaces[type], type, field, recordId); + return await file.delete(namespaces[type], type, field, recordId); } export async function getFileMetadata( @@ -39,7 +47,7 @@ export async function getFileMetadata( field: TypeWithFiles[T]["fields"], recordId: string, ): Promise { - return await tailordb.file.getMetadata(namespaces[type], type, field, recordId); + return await file.getMetadata(namespaces[type], type, field, recordId); } export async function openFileDownloadStream( @@ -47,5 +55,5 @@ export async function openFileDownloadStream( field: TypeWithFiles[T]["fields"], recordId: string, ): Promise { - return await tailordb.file.openDownloadStream(namespaces[type], type, field, recordId); + return await file.openDownloadStream(namespaces[type], type, field, recordId); } diff --git a/packages/sdk-codemod/codemods/v2/tailordb-namespace/codemod.yaml b/packages/sdk-codemod/codemods/v2/tailordb-namespace/codemod.yaml new file mode 100644 index 000000000..83219b1f8 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/tailordb-namespace/codemod.yaml @@ -0,0 +1,7 @@ +name: "@tailor-platform/tailordb-namespace" +version: "1.0.0" +description: "Rename the deprecated capital-cased `Tailordb` ambient namespace to lowercase `tailordb` (e.g. `Tailordb.QueryResult` → `tailordb.QueryResult`)" +engine: jssg +language: typescript +since: "1.0.0" +until: "2.0.0" diff --git a/packages/sdk-codemod/codemods/v2/tailordb-namespace/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/tailordb-namespace/scripts/transform.ts new file mode 100644 index 000000000..a803bbea5 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/tailordb-namespace/scripts/transform.ts @@ -0,0 +1,36 @@ +// Members exposed by the deprecated `Tailordb` ambient namespace from +// `@tailor-platform/function-types`. Each was a type-only declaration that +// has been re-published under the new lowercase `tailordb` namespace by the +// SDK. Anything outside this list is left untouched so user-defined symbols +// that happen to share the `Tailordb.` prefix are not rewritten by accident. +const TAILORDB_MEMBERS = ["QueryResult", "CommandType", "Client"] as const; + +const MEMBER_GROUP = TAILORDB_MEMBERS.join("|"); + +// Match `Tailordb.` with word boundaries so neither prefix nor +// suffix collisions (e.g. `MyTailordb.X`, `Tailordb.QueryResultExtra`) are +// rewritten. Generic-argument lists and `typeof` qualifiers are not part of +// the match — they fall outside the boundary and are preserved verbatim. +const PATTERN = new RegExp(String.raw`\bTailordb\.(${MEMBER_GROUP})\b`, "g"); + +/** + * Rewrite references to the deprecated capital-cased `Tailordb` ambient + * namespace to the new lowercase `tailordb` namespace. The capital-cased + * namespace was inherited from `@tailor-platform/function-types`; the SDK + * keeps it as a `@deprecated` alias in v1 and removes it in v2. + * + * Only the known type-only members (`QueryResult`, `CommandType`, `Client`) + * are rewritten so that unrelated user-defined symbols sharing the + * `Tailordb.` prefix remain untouched. Both type-position references + * (`Tailordb.QueryResult`, `typeof Tailordb.Client`) and value-position + * references (`new Tailordb.Client(...)`) are handled by the same rule. + * @param source - File contents + * @param _filePath - Absolute path to the file (kept for the runner signature) + * @returns Transformed source or null when nothing matched. + */ +export default function transform(source: string, _filePath: string): string | null { + if (!source.includes("Tailordb.")) return null; + PATTERN.lastIndex = 0; + const updated = source.replace(PATTERN, (_match, member: string) => `tailordb.${member}`); + return updated === source ? null : updated; +} diff --git a/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/basic-query-result/expected.ts b/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/basic-query-result/expected.ts new file mode 100644 index 000000000..f7f20316a --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/basic-query-result/expected.ts @@ -0,0 +1,5 @@ +async function fetchRows(): Promise> { + return {} as tailordb.QueryResult; +} + +const command: tailordb.CommandType = "SELECT"; diff --git a/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/basic-query-result/input.ts b/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/basic-query-result/input.ts new file mode 100644 index 000000000..7ced83968 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/basic-query-result/input.ts @@ -0,0 +1,5 @@ +async function fetchRows(): Promise> { + return {} as Tailordb.QueryResult; +} + +const command: Tailordb.CommandType = "SELECT"; diff --git a/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/client-instance-and-typeof/expected.ts b/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/client-instance-and-typeof/expected.ts new file mode 100644 index 000000000..c0374a437 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/client-instance-and-typeof/expected.ts @@ -0,0 +1,7 @@ +function takeClient(client: tailordb.Client): void { + void client; +} + +function makeClient(Ctor: typeof tailordb.Client): tailordb.Client { + return new Ctor({ namespace: "demo" }); +} diff --git a/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/client-instance-and-typeof/input.ts b/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/client-instance-and-typeof/input.ts new file mode 100644 index 000000000..d9c1d4f62 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/client-instance-and-typeof/input.ts @@ -0,0 +1,7 @@ +function takeClient(client: Tailordb.Client): void { + void client; +} + +function makeClient(Ctor: typeof Tailordb.Client): Tailordb.Client { + return new Ctor({ namespace: "demo" }); +} diff --git a/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/generic-arg/expected.ts b/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/generic-arg/expected.ts new file mode 100644 index 000000000..1648242a8 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/generic-arg/expected.ts @@ -0,0 +1,9 @@ +interface Row { + id: string; +} + +type RowsResult = tailordb.QueryResult; + +function describe(result: tailordb.QueryResult<{ id: number; tags: readonly string[] }>): number { + return result.rowCount; +} diff --git a/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/generic-arg/input.ts b/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/generic-arg/input.ts new file mode 100644 index 000000000..755d627f3 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/generic-arg/input.ts @@ -0,0 +1,9 @@ +interface Row { + id: string; +} + +type RowsResult = Tailordb.QueryResult; + +function describe(result: Tailordb.QueryResult<{ id: number; tags: readonly string[] }>): number { + return result.rowCount; +} diff --git a/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/multiple-members/expected.ts b/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/multiple-members/expected.ts new file mode 100644 index 000000000..47d38d488 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/multiple-members/expected.ts @@ -0,0 +1,9 @@ +async function run(): Promise> { + const cmd: tailordb.CommandType = "SELECT"; + const client: tailordb.Client = new (tailordb.Client as typeof tailordb.Client)({ + namespace: "demo", + }); + void cmd; + void client; + return {} as tailordb.QueryResult; +} diff --git a/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/multiple-members/input.ts b/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/multiple-members/input.ts new file mode 100644 index 000000000..ac0d7fd2c --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/multiple-members/input.ts @@ -0,0 +1,9 @@ +async function run(): Promise> { + const cmd: Tailordb.CommandType = "SELECT"; + const client: Tailordb.Client = new (Tailordb.Client as typeof Tailordb.Client)({ + namespace: "demo", + }); + void cmd; + void client; + return {} as Tailordb.QueryResult; +} diff --git a/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/no-match/input.ts b/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/no-match/input.ts new file mode 100644 index 000000000..2b920d01f --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/no-match/input.ts @@ -0,0 +1,8 @@ +import { createResolver, t } from "@tailor-platform/sdk"; + +export default createResolver({ + name: "hello", + type: "Query", + output: t.string(), + body: () => "world", +}); diff --git a/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/unrelated-prefix/input.ts b/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/unrelated-prefix/input.ts new file mode 100644 index 000000000..915f92899 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/tailordb-namespace/tests/unrelated-prefix/input.ts @@ -0,0 +1,15 @@ +// Identifiers that merely *start* with `Tailordb` (or have an unknown +// member) must not be rewritten by the codemod. + +namespace MyTailordb { + export type QueryResult = { rows: T[] }; +} + +type Mine = MyTailordb.QueryResult<{ id: string }>; + +// Unknown member on the deprecated namespace stays untouched (best-effort +// safety net — there is no `Tailordb.NotAType` on the SDK side). +type Untouched = Tailordb.NotAType; + +// A suffix-extended member name must also stay intact. +type Extra = Tailordb.QueryResultExtra; diff --git a/packages/sdk-codemod/src/registry.ts b/packages/sdk-codemod/src/registry.ts index ddd4bf315..886a22ab9 100644 --- a/packages/sdk-codemod/src/registry.ts +++ b/packages/sdk-codemod/src/registry.ts @@ -77,6 +77,16 @@ const allCodemods: CodemodPackage[] = [ scriptPath: "v2/auth-invoker-unwrap/scripts/transform.js", legacyPatterns: ["auth.invoker"], }, + { + id: "v2/tailordb-namespace", + name: "Tailordb → tailordb (lowercase ambient namespace)", + description: + "Rewrite references to the deprecated capital-cased `Tailordb` ambient namespace (`Tailordb.QueryResult`, `Tailordb.CommandType`, `Tailordb.Client`, `typeof Tailordb.Client`) to the new lowercase `tailordb.*` namespace re-published by the SDK in place of `@tailor-platform/function-types`.", + since: "1.0.0", + until: "2.0.0", + scriptPath: "v2/tailordb-namespace/scripts/transform.js", + legacyPatterns: ["Tailordb."], + }, ]; /** diff --git a/packages/sdk-codemod/src/transform.test.ts b/packages/sdk-codemod/src/transform.test.ts index cef6eb1ac..fd0d9c263 100644 --- a/packages/sdk-codemod/src/transform.test.ts +++ b/packages/sdk-codemod/src/transform.test.ts @@ -90,4 +90,8 @@ describe("codemod transforms", () => { it("v2/auth-invoker-unwrap transforms correctly", async () => { await runFixtureCases("v2/auth-invoker-unwrap"); }); + + it("v2/tailordb-namespace transforms correctly", async () => { + await runFixtureCases("v2/tailordb-namespace"); + }); }); diff --git a/packages/sdk-codemod/tsdown.config.ts b/packages/sdk-codemod/tsdown.config.ts index c6c799ed5..e100e60ff 100644 --- a/packages/sdk-codemod/tsdown.config.ts +++ b/packages/sdk-codemod/tsdown.config.ts @@ -25,6 +25,8 @@ export default defineConfig([ "v2/cli-rename/scripts/transform": "codemods/v2/cli-rename/scripts/transform.ts", "v2/auth-invoker-unwrap/scripts/transform": "codemods/v2/auth-invoker-unwrap/scripts/transform.ts", + "v2/tailordb-namespace/scripts/transform": + "codemods/v2/tailordb-namespace/scripts/transform.ts", }, format: ["esm"], target: "node18", diff --git a/packages/sdk/README.md b/packages/sdk/README.md index ae1ee7cee..5ab3a6806 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -85,6 +85,7 @@ the installed SDK version. Files are copied (not symlinked) so they survive ### Guides +- [Runtime API](./docs/runtime.md) - Typed wrappers for `tailor.iconv`, `tailor.secretmanager`, `tailor.idp`, `tailor.workflow`, `tailor.context`, `tailor.authconnection`, and `tailordb.file` - [Testing Guide](./docs/testing.md) - Unit and E2E testing patterns - [CLI Reference](./docs/cli-reference.md) - Command-line interface documentation diff --git a/packages/sdk/agent-skills/tailor-sdk/SKILL.md b/packages/sdk/agent-skills/tailor-sdk/SKILL.md index ee1852d2a..7d1dcdcf2 100644 --- a/packages/sdk/agent-skills/tailor-sdk/SKILL.md +++ b/packages/sdk/agent-skills/tailor-sdk/SKILL.md @@ -17,6 +17,7 @@ Use these files as the single source of truth: - `node_modules/@tailor-platform/sdk/docs/cli-reference.md` - `node_modules/@tailor-platform/sdk/docs/cli/*.md` - `node_modules/@tailor-platform/sdk/docs/testing.md` +- `node_modules/@tailor-platform/sdk/docs/runtime.md` ## Working Rules @@ -32,3 +33,4 @@ Use these files as the single source of truth: - Service details: `docs/services/*.md` - CLI commands: `docs/cli-reference.md` and `docs/cli/*.md` - Testing patterns: `docs/testing.md` +- Runtime API wrappers (`tailor.iconv`, `tailor.secretmanager`, `tailor.idp`, `tailor.workflow`, `tailor.context`, `tailor.authconnection`, `tailordb.file`): `docs/runtime.md` diff --git a/packages/sdk/docs/runtime.md b/packages/sdk/docs/runtime.md new file mode 100644 index 000000000..0d3e31679 --- /dev/null +++ b/packages/sdk/docs/runtime.md @@ -0,0 +1,113 @@ +# Runtime API + +`@tailor-platform/sdk/runtime` provides typed wrappers for the `tailor.*` and `tailordb.file` APIs that the Tailor Platform Function runtime injects into the global scope at execution time. The wrappers are thin and delegate to the platform-provided globals; they exist so that you can: + +- Reach the runtime API without relying on a separate ambient `.d.ts` package +- Get IDE-friendly imports (`iconv.convert`, `idp.Client`, …) instead of unmemorable `tailor.iconv.convert(...)` calls +- Use the same module surface in resolvers, executors, and workflows + +The wrappers and their associated types are self-contained — you do not need to activate any ambient globals to use them. If you also want `tailor.iconv.convert(...)` calls to type-check, opt into the globals via the [Activating the global types](#activating-the-global-types) section below. + +## Quick Start + +```ts +import { + iconv, + secretmanager, + authconnection, + idp, + workflow, + context, + file, +} from "@tailor-platform/sdk/runtime"; + +const utf8 = iconv.convert(sjisBuffer, "Shift_JIS", "UTF-8"); + +const apiKey = await secretmanager.getSecret("my-vault", "API_KEY"); + +const token = await authconnection.getConnectionToken("google"); + +const client = new idp.Client({ namespace: "my-namespace" }); +const { users } = await client.users({ first: 10 }); + +const executionId = await workflow.triggerWorkflow("approval", { reportId }); + +const invoker = context.getInvoker(); + +const { metadata } = await file.upload("my-namespace", "Document", "attachment", recordId, bytes); +``` + +## Subpath imports + +Each namespace can also be imported individually so you only pull what you need: + +```ts +import * as iconv from "@tailor-platform/sdk/runtime/iconv"; +import type { ListUsersResponse, ClientConfig } from "@tailor-platform/sdk/runtime/idp"; +``` + +## Activating the global types + +Most users do not need to touch the globals entry — `@tailor-platform/sdk/runtime` (and its subpath modules) cover the same surface without depending on any ambient declaration. + +For backwards compatibility with the previous `@tailor-platform/function-types`-based setup, the SDK still activates the ambient `tailor.*` / `tailordb.*` types automatically when you import from `@tailor-platform/sdk`. **This implicit activation will be removed in v2.0**; new code should prefer the typed wrappers from `@tailor-platform/sdk/runtime`. + +If you want to opt into the globals explicitly (or you are migrating ahead of v2.0), add a single side-effect import anywhere in your project: + +```ts +import "@tailor-platform/sdk/runtime/globals"; +``` + +Or register the entry in `tsconfig.json`: + +```jsonc +{ + "compilerOptions": { + "types": ["@tailor-platform/sdk/runtime/globals"], + }, +} +``` + +## Namespaces + +The runtime entry re-exports the following namespaces. Detailed signatures, parameters, and return types live in the JSDoc next to each export — hover the symbol in your IDE or browse the source. + +- `iconv` — character encoding conversion (`convert`, `convertBuffer`, `decode`, `encode`, `encodings`, `Iconv`) +- `secretmanager` — secret-vault access (`getSecret`, `getSecrets`) +- `authconnection` — OAuth-style connection tokens (`getConnectionToken`) +- `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`) + +## Testing + +`@tailor-platform/sdk/vitest` ships mock controllers for every runtime namespace. Pair them with the `tailor-runtime` Vitest environment so your unit tests run against the same wrappers your production code does. + +```ts +import { iconv, secretmanager } from "@tailor-platform/sdk/runtime"; +import { iconvMock, secretmanagerMock } from "@tailor-platform/sdk/vitest"; +import { beforeEach, expect, test } from "vitest"; + +beforeEach(() => { + iconvMock.reset(); + secretmanagerMock.reset(); +}); + +test("encodes via iconv", () => { + iconvMock.setResolver(() => new Uint8Array([0x82, 0xa0])); + + const out = iconv.convert("あ", "UTF-8", "Shift_JIS"); + + expect(out).toEqual(new Uint8Array([0x82, 0xa0])); + expect(iconvMock.calls[0]?.method).toBe("convert"); +}); + +test("reads from a vault", async () => { + secretmanagerMock.setSecrets({ "my-vault": { API_KEY: "sk-123" } }); + + await expect(secretmanager.getSecret("my-vault", "API_KEY")).resolves.toBe("sk-123"); +}); +``` + +See [Testing Guide](./testing.md#runtime-environment-emulation-beta) for the full list of mock controllers and the `tailor-runtime` environment setup. diff --git a/packages/sdk/docs/testing.md b/packages/sdk/docs/testing.md index 7597673d8..647175fbb 100644 --- a/packages/sdk/docs/testing.md +++ b/packages/sdk/docs/testing.md @@ -228,6 +228,27 @@ 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. + +```typescript +test("mock file download stream", async () => { + fileMock.enqueueResult([ + { + type: "metadata", + metadata: { contentType: "image/png", fileSize: 3, sha256sum: "abc" }, + }, + { type: "chunk", data: new Uint8Array([1, 2]), position: 0 }, + { type: "chunk", data: new Uint8Array([3]), position: 2 }, + { type: "complete" }, + ]); + + const stream = await tailordb.file.openDownloadStream("ns", "Doc", "attachment", "r-1"); + const items = []; + for await (const item of stream) items.push(item); + expect(items).toHaveLength(4); +}); +``` + ### Iconv Mock ```typescript diff --git a/packages/sdk/package.json b/packages/sdk/package.json index a62177e34..58f8e602d 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -87,6 +87,51 @@ "types": "./dist/vitest/environment.d.mts", "import": "./dist/vitest/environment.mjs", "default": "./dist/vitest/environment.mjs" + }, + "./runtime": { + "types": "./dist/runtime/index.d.mts", + "import": "./dist/runtime/index.mjs", + "default": "./dist/runtime/index.mjs" + }, + "./runtime/globals": { + "types": "./dist/runtime/globals.d.mts", + "import": "./dist/runtime/globals.mjs", + "default": "./dist/runtime/globals.mjs" + }, + "./runtime/iconv": { + "types": "./dist/runtime/iconv.d.mts", + "import": "./dist/runtime/iconv.mjs", + "default": "./dist/runtime/iconv.mjs" + }, + "./runtime/secretmanager": { + "types": "./dist/runtime/secretmanager.d.mts", + "import": "./dist/runtime/secretmanager.mjs", + "default": "./dist/runtime/secretmanager.mjs" + }, + "./runtime/authconnection": { + "types": "./dist/runtime/authconnection.d.mts", + "import": "./dist/runtime/authconnection.mjs", + "default": "./dist/runtime/authconnection.mjs" + }, + "./runtime/idp": { + "types": "./dist/runtime/idp.d.mts", + "import": "./dist/runtime/idp.mjs", + "default": "./dist/runtime/idp.mjs" + }, + "./runtime/workflow": { + "types": "./dist/runtime/workflow.d.mts", + "import": "./dist/runtime/workflow.mjs", + "default": "./dist/runtime/workflow.mjs" + }, + "./runtime/context": { + "types": "./dist/runtime/context.d.mts", + "import": "./dist/runtime/context.mjs", + "default": "./dist/runtime/context.mjs" + }, + "./runtime/file": { + "types": "./dist/runtime/file.d.mts", + "import": "./dist/runtime/file.mjs", + "default": "./dist/runtime/file.mjs" } }, "scripts": { @@ -130,7 +175,6 @@ "@oxc-project/types": "0.127.0", "@standard-schema/spec": "1.1.0", "@tailor-platform/function-kysely-tailordb": "0.1.3", - "@tailor-platform/function-types": "0.8.5", "@toiroakr/lines-db": "0.9.2", "@toiroakr/read-multiline": "0.4.1", "@urql/core": "6.0.1", diff --git a/packages/sdk/src/cli/shared/mock.ts b/packages/sdk/src/cli/shared/mock.ts index 1507761e6..92f254791 100644 --- a/packages/sdk/src/cli/shared/mock.ts +++ b/packages/sdk/src/cli/shared/mock.ts @@ -21,8 +21,8 @@ export function installCliTailordbStub(): void { constructor(_config: { namespace: string }) {} async connect(): Promise {} async end(): Promise {} - async queryObject(): Promise> { - return {} as Promise>; + async queryObject(): Promise> { + return {} as Promise>; } }, }; diff --git a/packages/sdk/src/configure/index.ts b/packages/sdk/src/configure/index.ts index 7faede4a5..fa5dd0e1f 100644 --- a/packages/sdk/src/configure/index.ts +++ b/packages/sdk/src/configure/index.ts @@ -1,4 +1,3 @@ -/// import { t as _t } from "@/configure/types"; import type * as helperTypes from "@/types/helpers"; diff --git a/packages/sdk/src/configure/services/workflow/wait-point.test.ts b/packages/sdk/src/configure/services/workflow/wait-point.test.ts index 37378a711..4b8914af1 100644 --- a/packages/sdk/src/configure/services/workflow/wait-point.test.ts +++ b/packages/sdk/src/configure/services/workflow/wait-point.test.ts @@ -1,8 +1,9 @@ import { afterEach, describe, it, expect, expectTypeOf } from "vitest"; import { setupWaitPointMock, setupWorkflowMock } from "@/utils/test/mock"; import { defineWaitPoint, defineWaitPoints } from "./wait-point"; +import type { TailorRuntime } from "@/runtime"; -const TailorGlobal = globalThis as { tailor?: { workflow?: Record } }; +const TailorGlobal = globalThis as { tailor?: TailorRuntime }; describe("defineWaitPoints", () => { afterEach(() => { diff --git a/packages/sdk/src/configure/services/workflow/wait-point.ts b/packages/sdk/src/configure/services/workflow/wait-point.ts index 659788fca..375ef3852 100644 --- a/packages/sdk/src/configure/services/workflow/wait-point.ts +++ b/packages/sdk/src/configure/services/workflow/wait-point.ts @@ -1,4 +1,5 @@ import { brandValue } from "@/utils/brand"; +import type { TailorWorkflowAPI } from "@/runtime/workflow"; import type { JsonCompatible } from "@/types/helpers"; /** @@ -36,18 +37,7 @@ interface WaitPointWithSetter { } function getPlatformWorkflow() { - const platform = globalThis as { - tailor?: { - workflow?: { - wait: (k: string, p?: unknown) => unknown; - resolve: ( - e: string, - k: string, - c: (p: unknown) => unknown | Promise, - ) => Promise; - }; - }; - }; + const platform = globalThis as { tailor?: { workflow?: TailorWorkflowAPI } }; const workflow = platform.tailor?.workflow; if (!workflow) { throw new Error( diff --git a/packages/sdk/src/plugin/builtin/file-utils/generate-file-utils.ts b/packages/sdk/src/plugin/builtin/file-utils/generate-file-utils.ts index 7144ab294..4d5ea1be8 100644 --- a/packages/sdk/src/plugin/builtin/file-utils/generate-file-utils.ts +++ b/packages/sdk/src/plugin/builtin/file-utils/generate-file-utils.ts @@ -36,6 +36,17 @@ export function generateUnifiedFileUtils( }) .join("\n"); + const importStatement = + multiline /* ts */ ` + import * as file from "@tailor-platform/sdk/runtime/file"; + import type { + FileUploadOptions, + FileUploadResponse, + FileMetadata, + FileStreamIterator, + } from "@tailor-platform/sdk/runtime/file"; + ` + "\n"; + const interfaceDefinition = multiline /* ts */ ` export interface TypeWithFiles { @@ -63,7 +74,7 @@ export function generateUnifiedFileUtils( field: TypeWithFiles[T]["fields"], recordId: string, ) { - return await tailordb.file.download(namespaces[type], type, field, recordId); + return await file.download(namespaces[type], type, field, recordId); } ` + "\n"; @@ -77,7 +88,7 @@ export function generateUnifiedFileUtils( data: string | ArrayBuffer | Uint8Array | number[], options?: FileUploadOptions, ): Promise { - return await tailordb.file.upload(namespaces[type], type, field, recordId, data, options); + return await file.upload(namespaces[type], type, field, recordId, data, options); } ` + "\n"; @@ -89,7 +100,7 @@ export function generateUnifiedFileUtils( field: TypeWithFiles[T]["fields"], recordId: string, ): Promise { - return await tailordb.file.delete(namespaces[type], type, field, recordId); + return await file.delete(namespaces[type], type, field, recordId); } ` + "\n"; @@ -101,7 +112,7 @@ export function generateUnifiedFileUtils( field: TypeWithFiles[T]["fields"], recordId: string, ): Promise { - return await tailordb.file.getMetadata(namespaces[type], type, field, recordId); + return await file.getMetadata(namespaces[type], type, field, recordId); } ` + "\n"; @@ -113,11 +124,12 @@ export function generateUnifiedFileUtils( field: TypeWithFiles[T]["fields"], recordId: string, ): Promise { - return await tailordb.file.openDownloadStream(namespaces[type], type, field, recordId); + return await file.openDownloadStream(namespaces[type], type, field, recordId); } ` + "\n"; return [ + importStatement, interfaceDefinition, namespacesDefinition, downloadFunction, diff --git a/packages/sdk/src/runtime/authconnection.test.ts b/packages/sdk/src/runtime/authconnection.test.ts new file mode 100644 index 000000000..59ec5395c --- /dev/null +++ b/packages/sdk/src/runtime/authconnection.test.ts @@ -0,0 +1,34 @@ +/** + * Tests for `@tailor-platform/sdk/runtime/authconnection` typed wrappers. + */ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import * as authconnection from "@/runtime/authconnection"; +import { authconnectionMock, cleanupMocks, injectMocks } from "@/vitest/mock"; + +describe("@tailor-platform/sdk/runtime/authconnection", () => { + beforeEach(() => { + injectMocks(globalThis); + authconnectionMock.reset(); + }); + + afterEach(() => { + cleanupMocks(globalThis); + }); + + test("getConnectionToken forwards to global and records call", async () => { + authconnectionMock.setTokens({ + google: { access_token: "ya29.xxx", expires_in: 3600 }, + }); + + const result = await authconnection.getConnectionToken("google"); + + expect(result).toEqual({ access_token: "ya29.xxx", expires_in: 3600 }); + expect(authconnectionMock.calls).toEqual([{ connectionName: "google" }]); + }); + + test("returns default token for unknown connection", async () => { + const result = await authconnection.getConnectionToken("unknown"); + + expect(result).toEqual({ access_token: "mock-token" }); + }); +}); diff --git a/packages/sdk/src/runtime/authconnection.ts b/packages/sdk/src/runtime/authconnection.ts new file mode 100644 index 000000000..741542e61 --- /dev/null +++ b/packages/sdk/src/runtime/authconnection.ts @@ -0,0 +1,41 @@ +/** + * Auth connection utilities. + * + * Thin typed wrapper around the platform-provided `tailor.authconnection` runtime API. + * At runtime this delegates to `globalThis.tailor.authconnection`. Use + * `authconnectionMock` from `@tailor-platform/sdk/vitest` to mock in unit tests. + * @example + * import { authconnection } from "@tailor-platform/sdk/runtime"; + * + * const token = await authconnection.getConnectionToken("my-connection"); + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** + * Platform API surface for `tailor.authconnection`. Describes the shape the + * platform runtime injects on `globalThis.tailor.authconnection`. + * + * Each method below is also re-exported as a top-level named export from this + * module so callers can either `import * as authconnection from + * "@tailor-platform/sdk/runtime/authconnection"` or pick individual methods. + */ +export interface TailorAuthconnectionAPI { + /** + * Returns the access token for the given auth connection. + * @param connectionName - Auth connection name as defined in tailor.config + * @returns Token payload (provider-specific shape) + */ + getConnectionToken(connectionName: string): Promise; +} + +const api = (): TailorAuthconnectionAPI => + (globalThis as { tailor: { authconnection: TailorAuthconnectionAPI } }).tailor.authconnection; + +/** + * See {@link TailorAuthconnectionAPI.getConnectionToken}. + * @param args - Forwarded to {@link TailorAuthconnectionAPI.getConnectionToken} + * @returns Token payload (provider-specific shape) + */ +export const getConnectionToken: TailorAuthconnectionAPI["getConnectionToken"] = (...args) => + api().getConnectionToken(...args); diff --git a/packages/sdk/src/runtime/context.test.ts b/packages/sdk/src/runtime/context.test.ts new file mode 100644 index 000000000..b9401421b --- /dev/null +++ b/packages/sdk/src/runtime/context.test.ts @@ -0,0 +1,44 @@ +/** + * Tests for `@tailor-platform/sdk/runtime/context` typed wrappers. + */ +import { afterEach, beforeEach, describe, expect, expectTypeOf, test, vi } from "vitest"; +import * as context from "@/runtime/context"; +import { cleanupMocks, injectMocks } from "@/vitest/mock"; + +describe("@tailor-platform/sdk/runtime/context", () => { + beforeEach(() => { + injectMocks(globalThis); + }); + + afterEach(() => { + cleanupMocks(globalThis); + vi.restoreAllMocks(); + }); + + test("getInvoker returns null for anonymous invocations", () => { + const result = context.getInvoker(); + + expectTypeOf(result).toEqualTypeOf(); + expect(result).toBeNull(); + }); + + test("getInvoker exposes SDK shape (attributes map + attributeList array)", () => { + vi.spyOn(globalThis.tailor.context, "getInvoker").mockReturnValue({ + id: "u-1", + type: "machine_user", + workspaceId: "ws-1", + attributes: ["role"], + attributeMap: { role: "MANAGER" }, + }); + + const invoker = context.getInvoker(); + + expect(invoker).toEqual({ + id: "u-1", + type: "machine_user", + workspaceId: "ws-1", + attributes: { role: "MANAGER" }, + attributeList: ["role"], + }); + }); +}); diff --git a/packages/sdk/src/runtime/context.ts b/packages/sdk/src/runtime/context.ts new file mode 100644 index 000000000..5fac684a5 --- /dev/null +++ b/packages/sdk/src/runtime/context.ts @@ -0,0 +1,76 @@ +/** + * Execution context utilities. + * + * Thin typed wrapper around the platform-provided `tailor.context` runtime API. + * At runtime this delegates to `globalThis.tailor.context`. + * @example + * import { context } from "@tailor-platform/sdk/runtime"; + * + * const invoker = context.getInvoker(); + * if (invoker) { + * console.log(invoker.id, invoker.type, invoker.attributes, invoker.attributeList); + * } + */ + +/** + * Information about the invoker of the current function execution. + * + * Matches the shape of `TailorUser` and `TailorActor` — `attributes` is the + * attribute map and `attributeList` is the array of attribute IDs. + */ +export interface Invoker { + /** The invoker's ID */ + id: string; + /** The invoker's type */ + type: "user" | "machine_user"; + /** The workspace ID */ + workspaceId: string; + /** A map of the invoker's attributes */ + attributes: Record; + /** The list of attribute IDs */ + attributeList: string[]; +} + +/** + * Raw platform-side invoker payload returned by `tailor.context.getInvoker()`. + * The wrapper normalizes this into {@link Invoker}. + * @internal + */ +export interface ContextInvoker { + /** The invoker's ID */ + id: string; + /** The invoker's type */ + type: "user" | "machine_user"; + /** The workspace ID */ + workspaceId: string; + /** The invoker's attribute IDs */ + attributes: string[]; + /** The invoker's attribute map */ + attributeMap: Record; +} + +/** + * Platform API surface for `tailor.context`. Describes the shape the platform + * runtime injects on `globalThis.tailor.context`. + * @internal + */ +export interface TailorContextAPI { + getInvoker(): ContextInvoker | null; +} + +/** + * Returns information about the invoker of the current function execution, + * or `null` for anonymous invocations. + * @returns Invoker details, or `null` when the call is anonymous + */ +export function getInvoker(): Invoker | null { + const raw = (globalThis as { tailor: { context: TailorContextAPI } }).tailor.context.getInvoker(); + if (!raw) return null; + return { + id: raw.id, + type: raw.type, + workspaceId: raw.workspaceId, + attributes: raw.attributeMap, + attributeList: raw.attributes, + }; +} diff --git a/packages/sdk/src/runtime/file.test.ts b/packages/sdk/src/runtime/file.test.ts new file mode 100644 index 000000000..3ec7acf52 --- /dev/null +++ b/packages/sdk/src/runtime/file.test.ts @@ -0,0 +1,137 @@ +/** + * Tests for `@tailor-platform/sdk/runtime/file` typed wrappers. + */ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import * as file from "@/runtime/file"; +import { cleanupMocks, fileMock, injectMocks } from "@/vitest/mock"; + +describe("@tailor-platform/sdk/runtime/file", () => { + beforeEach(() => { + injectMocks(globalThis); + fileMock.reset(); + }); + + afterEach(() => { + cleanupMocks(globalThis); + }); + + test("upload forwards args and records the call", async () => { + fileMock.enqueueResult({ metadata: { fileSize: 4, sha256sum: "abc" } }); + + const result = await file.upload("ns", "Doc", "blob", "rec-1", new Uint8Array([1, 2, 3, 4])); + + expect(result).toEqual({ metadata: { fileSize: 4, sha256sum: "abc" } }); + expect(fileMock.calls).toEqual([ + { + method: "upload", + namespace: "ns", + typeName: "Doc", + fieldName: "blob", + recordId: "rec-1", + }, + ]); + }); + + test("download forwards and returns the queued payload", async () => { + fileMock.enqueueResult({ + data: new Uint8Array([9, 9]), + metadata: { + contentType: "application/octet-stream", + fileSize: 2, + sha256sum: "h", + lastUploadedAt: "2026-01-01T00:00:00Z", + }, + }); + + const result = await file.download("ns", "Doc", "blob", "rec-1"); + + expect(result.data).toEqual(new Uint8Array([9, 9])); + expect(fileMock.calls[0]?.method).toBe("download"); + }); + + test("downloadAsBase64 forwards", async () => { + fileMock.enqueueResult({ + data: "AQID", + metadata: { + contentType: "application/octet-stream", + fileSize: 3, + sha256sum: "h", + lastUploadedAt: "2026-01-01T00:00:00Z", + }, + }); + + const result = await file.downloadAsBase64("ns", "Doc", "blob", "rec-1"); + + expect(result.data).toBe("AQID"); + expect(fileMock.calls[0]?.method).toBe("downloadAsBase64"); + }); + + test("getMetadata forwards", async () => { + fileMock.enqueueResult({ + contentType: "image/png", + fileSize: 100, + sha256sum: "x", + urlPath: "/url", + }); + + const meta = await file.getMetadata("ns", "Doc", "blob", "rec-1"); + + expect(meta.contentType).toBe("image/png"); + expect(fileMock.calls[0]?.method).toBe("getMetadata"); + }); + + test("delete forwards (re-exported from deleteFile)", async () => { + await file.delete("ns", "Doc", "blob", "rec-1"); + + expect(fileMock.calls).toEqual([ + { + method: "delete", + namespace: "ns", + typeName: "Doc", + fieldName: "blob", + recordId: "rec-1", + }, + ]); + }); + + test("openDownloadStream forwards and yields StreamValue chunks", async () => { + const sequence: file.StreamValue[] = [ + { + type: "metadata", + metadata: { contentType: "application/octet-stream", fileSize: 2, sha256sum: "h" }, + }, + { type: "chunk", data: new Uint8Array([1]), position: 0 }, + { type: "chunk", data: new Uint8Array([2]), position: 1 }, + { type: "complete" }, + ]; + fileMock.enqueueResult(sequence); + + const stream = await file.openDownloadStream("ns", "Doc", "blob", "rec-1"); + + const chunks: file.StreamValue[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + expect(chunks).toEqual(sequence); + expect(fileMock.calls[0]?.method).toBe("openDownloadStream"); + }); + + test("TailorDBFileError structurally matches globalThis class", () => { + const TailorDBFileError = ( + globalThis as unknown as { + TailorDBFileError: new ( + m: string, + c?: file.TailorDBFileErrorCode, + ) => Error & { code?: file.TailorDBFileErrorCode }; + } + ).TailorDBFileError; + const err = new TailorDBFileError("operation failed", "OPERATION_FAILED"); + expect(err.name).toBe("TailorDBFileError"); + expect(err.code).toBe("OPERATION_FAILED"); + // Type-level: file.TailorDBFileError is a structural interface that the + // global class instances satisfy (not a direct alias of the class itself). + const _typed: file.TailorDBFileError = err as file.TailorDBFileError; + expect(_typed).toBe(err); + }); +}); diff --git a/packages/sdk/src/runtime/file.ts b/packages/sdk/src/runtime/file.ts new file mode 100644 index 000000000..fe7ebfcad --- /dev/null +++ b/packages/sdk/src/runtime/file.ts @@ -0,0 +1,260 @@ +/** + * TailorDB file (BLOB) utilities. + * + * Thin typed wrapper around the platform-provided `tailordb.file` runtime API. + * At runtime this delegates to `globalThis.tailordb.file`. Use `fileMock` from + * `@tailor-platform/sdk/vitest` to mock these calls in unit tests. + * @example + * import { file } from "@tailor-platform/sdk/runtime"; + * + * const { metadata } = await file.upload( + * "my-namespace", + * "Document", + * "attachment", + * recordId, + * bytes, + * ); + */ + +/** Upload response metadata. */ +export interface UploadMetadata { + fileSize: number; + sha256sum: string; +} + +/** Download response metadata. */ +export interface DownloadMetadata { + contentType: string; + fileSize: number; + sha256sum: string; + lastUploadedAt: string; +} + +/** File metadata (for {@link getMetadata}). */ +export interface FileMetadata { + contentType: string; + fileSize: number; + sha256sum: string; + urlPath: string; + lastUploadedAt?: string; +} + +/** Stream metadata (first chunk emitted by {@link openDownloadStream}). */ +export interface StreamMetadata { + contentType: string; + fileSize: number; + sha256sum: string; +} + +/** Upload options. */ +export interface FileUploadOptions { + contentType?: string; +} + +/** Upload response. */ +export interface FileUploadResponse { + metadata: UploadMetadata; +} + +/** Download response. */ +export interface FileDownloadResponse { + data: Uint8Array; + metadata: DownloadMetadata; +} + +/** Download-as-Base64 response. */ +export interface FileDownloadAsBase64Response { + data: string; + metadata: DownloadMetadata; +} + +/** Stream chunk types emitted by {@link FileStreamIterator}. */ +export type StreamValue = + | { type: "metadata"; metadata: StreamMetadata } + | { type: "chunk"; data: Uint8Array; position: number } + | { type: "complete" }; + +/** Stream iterator returned by {@link openDownloadStream}. */ +export interface FileStreamIterator extends AsyncIterableIterator { + next(): Promise>; + close(): Promise; +} + +/** Error code emitted by {@link TailorDBFileError}. */ +export type TailorDBFileErrorCode = + | "INVALID_PARAMS" + | "INVALID_DATA_TYPE" + | "OPERATION_FAILED" + | "DELETE_FAILED" + | "STREAM_OPEN_FAILED" + | "STREAM_READ_ERROR" + | "STREAM_ERROR" + | "FILE_TOO_LARGE"; + +/** + * Type-only shape of the `TailorDBFileError` runtime class. The class itself + * is provided by the platform runtime (and by `injectMocks` in tests); this + * interface mirrors it so callers can `import type { TailorDBFileError }` from + * the wrapper module without depending on any ambient declaration. + */ +export interface TailorDBFileError extends Error { + name: "TailorDBFileError"; + code?: TailorDBFileErrorCode; + cause?: unknown; +} + +/** + * Platform API surface for `tailordb.file`. Describes the shape the platform + * runtime injects on `globalThis.tailordb.file`. + * + * Each method below is also re-exported as a top-level named export from this + * module (e.g. `upload`, `download`, `deleteFile`) so callers can either + * `import * as file from "@tailor-platform/sdk/runtime/file"` or pick + * individual methods. + */ +export interface TailorDBFileAPI { + /** + * Upload a file to TailorDB. + * @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 data - File contents + * @param options - Upload options (e.g. `contentType`) + * @returns Upload response containing the file metadata + */ + upload( + namespace: string, + typeName: string, + fieldName: string, + recordId: string, + data: string | ArrayBuffer | Uint8Array | number[], + options?: FileUploadOptions, + ): Promise; + + /** + * Download a file from TailorDB. + * + * Throws `TailorDBFileError` with code `FILE_TOO_LARGE` when the file + * exceeds 10MB — use {@link openDownloadStream} for large files. + * @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 Bytes and metadata for the file + */ + download( + namespace: string, + typeName: string, + fieldName: string, + recordId: string, + ): Promise; + + /** + * 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. + * @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 Base64-encoded contents and metadata for the file + */ + downloadAsBase64( + namespace: string, + typeName: string, + fieldName: string, + recordId: string, + ): Promise; + + /** + * Delete a file from TailorDB. Exported as `deleteFile` (and aliased as + * `delete`) so it can be used both with named and namespace imports. + * @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 Resolves once the file has been deleted + */ + delete(namespace: string, typeName: string, fieldName: string, recordId: string): Promise; + + /** + * Get file metadata from TailorDB. + * @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 Metadata for the stored file + */ + getMetadata( + namespace: string, + typeName: string, + fieldName: string, + recordId: string, + ): Promise; + + /** + * Open a download stream for large files. + * @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 Async iterator yielding file chunks; call `close()` to release resources + */ + openDownloadStream( + namespace: string, + typeName: string, + fieldName: string, + recordId: string, + ): Promise; +} + +const api = (): TailorDBFileAPI => + (globalThis as { tailordb: { file: TailorDBFileAPI } }).tailordb.file; + +/** + * See {@link TailorDBFileAPI.upload}. + * @param args - Forwarded to {@link TailorDBFileAPI.upload} + * @returns Upload response containing the file metadata + */ +export const upload: TailorDBFileAPI["upload"] = (...args) => api().upload(...args); + +/** + * See {@link TailorDBFileAPI.download}. + * @param args - Forwarded to {@link TailorDBFileAPI.download} + * @returns Bytes and metadata for the file + */ +export const download: TailorDBFileAPI["download"] = (...args) => api().download(...args); + +/** + * See {@link TailorDBFileAPI.downloadAsBase64}. + * @param args - Forwarded to {@link TailorDBFileAPI.downloadAsBase64} + * @returns Base64-encoded contents and metadata for the file + */ +export const downloadAsBase64: TailorDBFileAPI["downloadAsBase64"] = (...args) => + api().downloadAsBase64(...args); + +/** + * See {@link TailorDBFileAPI.delete}. + * @param args - Forwarded to {@link TailorDBFileAPI.delete} + * @returns Resolves once the file has been deleted + */ +export const deleteFile: TailorDBFileAPI["delete"] = (...args) => api().delete(...args); + +/** + * See {@link TailorDBFileAPI.getMetadata}. + * @param args - Forwarded to {@link TailorDBFileAPI.getMetadata} + * @returns Metadata for the stored file + */ +export const getMetadata: TailorDBFileAPI["getMetadata"] = (...args) => api().getMetadata(...args); + +/** + * See {@link TailorDBFileAPI.openDownloadStream}. + * @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); + +export { deleteFile as delete }; diff --git a/packages/sdk/src/runtime/globals.test.ts b/packages/sdk/src/runtime/globals.test.ts new file mode 100644 index 000000000..5acb283c8 --- /dev/null +++ b/packages/sdk/src/runtime/globals.test.ts @@ -0,0 +1,40 @@ +/** + * Type-level tests confirming that opting into `@tailor-platform/sdk/runtime/globals` + * activates the ambient `tailor.*` / `tailordb` declarations. + * + * These assertions are type-only — they reference `tailor`, `tailordb`, and + * `TailorDBFileError` solely through `typeof` so the test does not require + * the platform runtime to inject those values into the unit test environment. + */ +import "@/runtime/globals"; +import { describe, expectTypeOf, test } from "vitest"; + +describe("@tailor-platform/sdk/runtime/globals activates ambient globals", () => { + test("tailor.iconv.convert is declared as a function", () => { + expectTypeOf().toBeFunction(); + }); + + test("tailor.secretmanager.getSecret returns Promise", () => { + expectTypeOf>().toEqualTypeOf< + Promise + >(); + }); + + test("tailor.workflow.triggerWorkflow returns Promise", () => { + expectTypeOf>().toEqualTypeOf< + Promise + >(); + }); + + test("tailor.context.Invoker is exposed as a namespace type", () => { + expectTypeOf().not.toBeAny(); + }); + + test("tailordb.file.upload is declared as a function", () => { + expectTypeOf().toBeFunction(); + }); + + test("TailorDBFileError is declared as a global class", () => { + expectTypeOf().not.toBeAny(); + }); +}); diff --git a/packages/sdk/src/runtime/globals.ts b/packages/sdk/src/runtime/globals.ts new file mode 100644 index 000000000..03e94a939 --- /dev/null +++ b/packages/sdk/src/runtime/globals.ts @@ -0,0 +1,162 @@ +/** + * Ambient global type definitions for the Tailor Platform Function runtime. + * + * The Tailor Platform Function runtime injects `tailor.*` and `tailordb` + * objects into the global scope. This file declares their type signatures so + * they can be referenced from any TypeScript code that runs in (or is bundled + * for) the runtime. + * @example + * // Side-effect import to enable the global types in a single file: + * import "@tailor-platform/sdk/runtime/globals"; + * + * // Or register globally in tsconfig.json: + * // "compilerOptions": { "types": ["@tailor-platform/sdk/runtime/globals"] } + * + * Most users do not need to import this directly — `@tailor-platform/sdk/runtime` + * exposes typed wrappers that cover the same surface without relying on globals. + * + * The value declarations (`var tailor` / `var tailordb`) are typed via the + * `TailorRuntime` / `TailordbRuntime` aggregates re-exported from `.`, which in + * turn compose the per-service `TailorXxxAPI` types declared alongside each + * wrapper. Type-only `namespace tailor` / `namespace tailordb` declarations + * are merged with those vars so callers can write `tailor.idp.User` or + * `tailor.context.Invoker` in type position as well. + */ + +/* eslint-disable @typescript-eslint/no-namespace */ + +import type { + TailordbClientInstance, + TailordbCommandType, + TailordbQueryResult, + TailordbRuntime, + TailorRuntime, +} from "."; +import type { ContextInvoker } from "./context"; +import type { TailorDBFileErrorCode } from "./file"; +import type { IconvInstance } from "./iconv"; +import type { + ClientConfig as IdpClientConfig, + CreateUserInput as IdpCreateUserInput, + IdpClientInstance, + ListUsersOptions as IdpListUsersOptions, + ListUsersResponse as IdpListUsersResponse, + SendPasswordResetEmailInput as IdpSendPasswordResetEmailInput, + UpdateUserInput as IdpUpdateUserInput, + User as IdpUser, + UserQuery as IdpUserQuery, +} from "./idp"; +import type { + AuthInvoker as WorkflowAuthInvoker, + TriggerWorkflowOptions as WorkflowTriggerWorkflowOptions, +} from "./workflow"; + +declare global { + namespace tailordb { + type QueryResult = TailordbQueryResult; + type CommandType = TailordbCommandType; + type Client = TailordbClientInstance; + } + + // eslint-disable-next-line no-var + var tailordb: TailordbRuntime; + + /** + * @deprecated Use the lowercase `tailordb.*` namespace instead (e.g. + * `tailordb.QueryResult`, `tailordb.CommandType`, + * `typeof tailordb.Client`). This capital-cased namespace is retained + * only for backwards compatibility with `@tailor-platform/function-types` + * and will be removed in v2. Run + * `pnpm dlx @tailor-platform/sdk-codemod v2/tailordb-namespace` + * to migrate. + */ + namespace Tailordb { + /** + * @deprecated Use `tailordb.Client` (lowercase) instead. + * Will be removed in v2. + */ + class Client { + constructor(config: { namespace: string }); + connect(): Promise; + end(): Promise; + queryObject(sql: string, args?: readonly unknown[]): Promise>; + } + + /** + * @deprecated Use `tailordb.QueryResult` (lowercase) instead. + * Will be removed in v2. + */ + type QueryResult = TailordbQueryResult; + + /** + * @deprecated Use `tailordb.CommandType` (lowercase) instead. + * Will be removed in v2. + */ + type CommandType = TailordbCommandType; + } + + namespace tailor { + namespace iconv { + type Iconv = IconvInstance; + } + + namespace idp { + type Client = IdpClientInstance; + type ClientConfig = IdpClientConfig; + type User = IdpUser; + type UserQuery = IdpUserQuery; + type ListUsersOptions = IdpListUsersOptions; + type ListUsersResponse = IdpListUsersResponse; + type CreateUserInput = IdpCreateUserInput; + type UpdateUserInput = IdpUpdateUserInput; + type SendPasswordResetEmailInput = IdpSendPasswordResetEmailInput; + } + + namespace workflow { + type AuthInvoker = WorkflowAuthInvoker; + type TriggerWorkflowOptions = WorkflowTriggerWorkflowOptions; + } + + namespace context { + type Invoker = ContextInvoker; + } + } + + // eslint-disable-next-line no-var + var tailor: TailorRuntime; + + /** Custom error class for TailorDB File operations. */ + class TailorDBFileError extends Error { + constructor(message: string, code?: TailorDBFileErrorCode, cause?: unknown); + name: "TailorDBFileError"; + code?: TailorDBFileErrorCode; + cause?: unknown; + } + + /** Individual error entry attached to {@link TailorErrors}. */ + interface TailorErrorItem { + message: string; + path: (string | number)[]; + } + + /** + * Aggregate validation error raised by the Tailor Platform Function runtime. + * The runtime serializes the items into the `message` (`"TailorErrors: {...}"`) + * and also exposes them on `.errors`. + */ + class TailorErrors extends Error { + constructor(errors: TailorErrorItem[]); + name: "TailorErrors"; + errors: TailorErrorItem[]; + } + + /** + * Single-message error raised by the Tailor Platform Function runtime. + */ + class TailorErrorMessage extends Error { + constructor(message: string); + name: "TailorErrorMessage"; + } +} + +export {}; diff --git a/packages/sdk/src/runtime/iconv.test.ts b/packages/sdk/src/runtime/iconv.test.ts new file mode 100644 index 000000000..3488a5299 --- /dev/null +++ b/packages/sdk/src/runtime/iconv.test.ts @@ -0,0 +1,104 @@ +/** + * Tests for `@tailor-platform/sdk/runtime/iconv` typed wrappers. + * + * Verifies that each wrapper forwards to `globalThis.tailor.iconv.*` (recorded + * via `iconvMock.calls`) and that the return-type narrowing (`UTF-8` → + * `string`, otherwise `Uint8Array`) holds at the type level. + */ +import { afterEach, beforeEach, describe, expect, expectTypeOf, test } from "vitest"; +import * as iconv from "@/runtime/iconv"; +import { cleanupMocks, iconvMock, injectMocks } from "@/vitest/mock"; + +describe("@tailor-platform/sdk/runtime/iconv", () => { + beforeEach(() => { + injectMocks(globalThis); + iconvMock.reset(); + }); + + afterEach(() => { + cleanupMocks(globalThis); + }); + + test("convert forwards args and returns string for UTF-8 target", () => { + iconvMock.setResolver((method, args) => { + if (method === "convert" && args[2] === "UTF-8") return "decoded"; + return undefined; + }); + + const out = iconv.convert(new Uint8Array([0x61]), "Shift_JIS", "UTF-8"); + + expect(out).toBe("decoded"); + expectTypeOf(out).toEqualTypeOf(); + expect(iconvMock.calls).toEqual([ + { method: "convert", args: [new Uint8Array([0x61]), "Shift_JIS", "UTF-8"] }, + ]); + }); + + test("convert returns Uint8Array for non-UTF-8 target", () => { + iconvMock.setResolver(() => new Uint8Array([0x82, 0xa0])); + + const out = iconv.convert("あ", "UTF-8", "Shift_JIS"); + + expect(out).toBeInstanceOf(Uint8Array); + expectTypeOf(out).toEqualTypeOf(); + }); + + test("convertBuffer forwards and narrows return type", () => { + iconvMock.setResolver(() => "ok"); + + const out = iconv.convertBuffer(new Uint8Array(), "Shift_JIS", "UTF-8"); + + expect(out).toBe("ok"); + expectTypeOf(out).toEqualTypeOf(); + expect(iconvMock.calls[0]).toMatchObject({ method: "convertBuffer" }); + }); + + test("decode forwards args and returns string", () => { + iconvMock.setResolver(() => "hello"); + + const out = iconv.decode(new Uint8Array([0x68]), "ASCII"); + + expect(out).toBe("hello"); + expectTypeOf(out).toEqualTypeOf(); + expect(iconvMock.calls[0]).toMatchObject({ + method: "decode", + args: [new Uint8Array([0x68]), "ASCII"], + }); + }); + + test("encode narrows return type by encoding", () => { + iconvMock.setResolver((_method, args) => (args[1] === "UTF-8" ? "x" : new Uint8Array([1]))); + + const utf8 = iconv.encode("a", "UTF-8"); + const sjis = iconv.encode("a", "Shift_JIS"); + + expectTypeOf(utf8).toEqualTypeOf(); + expectTypeOf(sjis).toEqualTypeOf(); + expect(utf8).toBe("x"); + expect(sjis).toBeInstanceOf(Uint8Array); + }); + + test("encodings forwards and returns string[]", () => { + iconvMock.setResolver(() => ["UTF-8", "Shift_JIS"]); + + const list = iconv.encodings(); + + expect(list).toEqual(["UTF-8", "Shift_JIS"]); + expectTypeOf(list).toEqualTypeOf(); + }); + + test("Iconv class delegates convert to global Iconv", () => { + iconvMock.setResolver((method) => (method === "convert" ? "via-class" : undefined)); + + const conv = new iconv.Iconv("Shift_JIS", "UTF-8"); + const out = conv.convert(new Uint8Array([0x61])); + + expect(out).toBe("via-class"); + expect(iconvMock.calls).toEqual([ + { + method: "convert", + args: [new Uint8Array([0x61]), "Shift_JIS", "UTF-8"], + }, + ]); + }); +}); diff --git a/packages/sdk/src/runtime/iconv.ts b/packages/sdk/src/runtime/iconv.ts new file mode 100644 index 000000000..0e17aa594 --- /dev/null +++ b/packages/sdk/src/runtime/iconv.ts @@ -0,0 +1,153 @@ +/** + * Character encoding conversion utilities. + * + * Thin typed wrapper around the platform-provided `tailor.iconv` runtime API. + * At runtime this delegates to `globalThis.tailor.iconv`, which is provided by + * the Tailor Platform Function runtime. Use `iconvMock` from + * `@tailor-platform/sdk/vitest` to mock these calls in unit tests. + * @example + * import { iconv } from "@tailor-platform/sdk/runtime"; + * + * const utf8 = iconv.convert(sjisBuffer, "Shift_JIS", "UTF-8"); // string + * const sjis = iconv.convert("こんにちは", "UTF-8", "Shift_JIS"); // Uint8Array + * + * const conv = new iconv.Iconv("Shift_JIS", "UTF-8"); + * const out = conv.convert(sjisBuffer); + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** Instance methods exposed by `tailor.iconv.Iconv`. */ +export interface IconvInstance { + convert(input: string | Uint8Array | ArrayBuffer): string | Uint8Array; +} + +/** Constructor shape for `tailor.iconv.Iconv`. */ +export interface IconvConstructor { + new (fromEncoding: string, toEncoding: string): IconvInstance; +} + +/** + * Platform API surface for `tailor.iconv`. Describes the shape the platform + * runtime injects on `globalThis.tailor.iconv` so the wrapper and ambient + * globals stay in sync. + * + * Each method below is also re-exported as a top-level named export from this + * module (e.g. `convert`, `decode`, `encode`) so callers can either + * `import * as iconv from "@tailor-platform/sdk/runtime/iconv"` or pick + * individual methods. + */ +export interface TailorIconvAPI { + /** + * Convert a string or buffer between encodings. + * @param str - Input data to convert + * @param fromEncoding - Source encoding name + * @param toEncoding - Target encoding name + * @returns `string` when `toEncoding` is `"UTF8"` or `"UTF-8"`, otherwise `Uint8Array`. + */ + convert( + str: string | Uint8Array | ArrayBuffer, + fromEncoding: string, + toEncoding: T, + ): T extends "UTF8" | "UTF-8" ? string : Uint8Array; + + /** + * Convert a buffer between encodings. + * @param buffer - Input bytes to convert + * @param fromEncoding - Source encoding name + * @param toEncoding - Target encoding name + * @returns `string` when `toEncoding` is `"UTF8"` or `"UTF-8"`, otherwise `Uint8Array`. + */ + convertBuffer( + buffer: Uint8Array | ArrayBuffer, + fromEncoding: string, + toEncoding: T, + ): T extends "UTF8" | "UTF-8" ? string : Uint8Array; + + /** + * Decode a buffer to a UTF-8 string using the given source encoding. + * @param buffer - Input bytes + * @param encoding - Source encoding name + * @returns Decoded UTF-8 string + */ + decode(buffer: Uint8Array | ArrayBuffer, encoding: string): string; + + /** + * Encode a UTF-8 string into the given target encoding. + * @param str - Input string + * @param encoding - Target encoding name + * @returns `string` when `encoding` is `"UTF8"` or `"UTF-8"`, otherwise `Uint8Array`. + */ + encode( + str: string, + encoding: T, + ): T extends "UTF8" | "UTF-8" ? string : Uint8Array; + + /** + * Returns the list of supported encoding names. + * @returns Array of encoding names supported by the platform iconv runtime + */ + encodings(): string[]; + + /** Constructor for the stateful {@link Iconv} converter. */ + Iconv: IconvConstructor; +} + +const api = (): TailorIconvAPI => + (globalThis as { tailor: { iconv: TailorIconvAPI } }).tailor.iconv; + +/** + * See {@link TailorIconvAPI.convert}. + * @param args - Forwarded to {@link TailorIconvAPI.convert} + * @returns `string` when `toEncoding` is `"UTF8"` or `"UTF-8"`, otherwise `Uint8Array`. + */ +export const convert: TailorIconvAPI["convert"] = (...args) => api().convert(...args); + +/** + * See {@link TailorIconvAPI.convertBuffer}. + * @param args - Forwarded to {@link TailorIconvAPI.convertBuffer} + * @returns `string` when `toEncoding` is `"UTF8"` or `"UTF-8"`, otherwise `Uint8Array`. + */ +export const convertBuffer: TailorIconvAPI["convertBuffer"] = (...args) => + api().convertBuffer(...args); + +/** + * See {@link TailorIconvAPI.decode}. + * @param args - Forwarded to {@link TailorIconvAPI.decode} + * @returns Decoded UTF-8 string + */ +export const decode: TailorIconvAPI["decode"] = (...args) => api().decode(...args); + +/** + * See {@link TailorIconvAPI.encode}. + * @param args - Forwarded to {@link TailorIconvAPI.encode} + * @returns `string` when `encoding` is `"UTF8"` or `"UTF-8"`, otherwise `Uint8Array`. + */ +export const encode: TailorIconvAPI["encode"] = (...args) => api().encode(...args); + +/** + * See {@link TailorIconvAPI.encodings}. + * @returns Array of encoding names supported by the platform iconv runtime + */ +export const encodings: TailorIconvAPI["encodings"] = () => api().encodings(); + +/** + * Stateful converter for repeated conversions between a fixed encoding pair. + * Compatible with the `node-iconv` API surface. + */ +export class Iconv { + private impl: IconvInstance; + + constructor(fromEncoding: string, toEncoding: string) { + this.impl = new (api().Iconv)(fromEncoding, toEncoding); + } + + /** + * Convert input using this converter's fixed encoding pair. + * @param input - Bytes or string to convert + * @returns Encoded output (string for UTF-8 targets, otherwise `Uint8Array`). + */ + convert(input: string | Uint8Array | ArrayBuffer): string | Uint8Array { + return this.impl.convert(input); + } +} diff --git a/packages/sdk/src/runtime/idp.test.ts b/packages/sdk/src/runtime/idp.test.ts new file mode 100644 index 000000000..09ad71e3b --- /dev/null +++ b/packages/sdk/src/runtime/idp.test.ts @@ -0,0 +1,86 @@ +/** + * Tests for `@tailor-platform/sdk/runtime/idp` typed wrappers. + * + * Verifies that {@link idp.Client} forwards each method to the platform's + * `tailor.idp.Client` and records calls with method, args, and namespace. + */ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import * as idp from "@/runtime/idp"; +import { cleanupMocks, idpMock, injectMocks } from "@/vitest/mock"; + +describe("@tailor-platform/sdk/runtime/idp", () => { + beforeEach(() => { + injectMocks(globalThis); + idpMock.reset(); + }); + + afterEach(() => { + cleanupMocks(globalThis); + }); + + test("Client.user forwards args and namespace", async () => { + idpMock.enqueueResult({ id: "u-1", name: "alice", disabled: false }); + + const client = new idp.Client({ namespace: "ns" }); + const result = await client.user("u-1"); + + expect(result).toEqual({ id: "u-1", name: "alice", disabled: false }); + expect(idpMock.calls).toEqual([{ method: "user", args: ["u-1"], namespace: "ns" }]); + }); + + test("Client.userByName forwards", async () => { + idpMock.enqueueResult({ id: "u-1", name: "alice", disabled: false }); + + const client = new idp.Client({ namespace: "ns" }); + await client.userByName("alice"); + + expect(idpMock.calls).toEqual([{ method: "userByName", args: ["alice"], namespace: "ns" }]); + }); + + test("Client.users forwards options", async () => { + idpMock.enqueueResult({ + users: [{ id: "u-1", name: "alice", disabled: false }], + nextPageToken: null, + totalCount: 1, + }); + + const client = new idp.Client({ namespace: "ns" }); + const result = await client.users({ first: 10 }); + + expect(result.totalCount).toBe(1); + expect(idpMock.calls).toEqual([{ method: "users", args: [{ first: 10 }], namespace: "ns" }]); + }); + + test("Client.createUser / updateUser / deleteUser forward", async () => { + idpMock.enqueueResults( + { id: "u-2", name: "bob", disabled: false }, + { id: "u-2", name: "bob2", disabled: false }, + true, + ); + + const client = new idp.Client({ namespace: "ns" }); + await client.createUser({ name: "bob", password: "p" }); + await client.updateUser({ id: "u-2", name: "bob2" }); + const removed = await client.deleteUser("u-2"); + + expect(removed).toBe(true); + expect(idpMock.calls.map((c) => c.method)).toEqual(["createUser", "updateUser", "deleteUser"]); + }); + + test("Client.sendPasswordResetEmail forwards", async () => { + const client = new idp.Client({ namespace: "ns" }); + const ok = await client.sendPasswordResetEmail({ + userId: "u-1", + redirectUri: "https://example.com/reset", + }); + + expect(ok).toBe(true); + expect(idpMock.calls).toEqual([ + { + method: "sendPasswordResetEmail", + args: [{ userId: "u-1", redirectUri: "https://example.com/reset" }], + namespace: "ns", + }, + ]); + }); +}); diff --git a/packages/sdk/src/runtime/idp.ts b/packages/sdk/src/runtime/idp.ts new file mode 100644 index 000000000..432b89297 --- /dev/null +++ b/packages/sdk/src/runtime/idp.ts @@ -0,0 +1,191 @@ +/** + * IDP (Identity Provider) utilities. + * + * Thin typed wrapper around the platform-provided `tailor.idp` runtime API. + * At runtime this delegates to `globalThis.tailor.idp`. Use `idpMock` from + * `@tailor-platform/sdk/vitest` to mock these calls in unit tests. + * @example + * import { idp } from "@tailor-platform/sdk/runtime"; + * + * const client = new idp.Client({ namespace: "my-namespace" }); + * const { users } = await client.users({ first: 10 }); + */ + +/** Configuration object for {@link Client}. */ +export interface ClientConfig { + namespace: string; +} + +/** User record returned by IDP operations. */ +export interface User { + id: string; + name: string; + disabled: boolean; + createdAt?: string; + updatedAt?: string; +} + +/** Filter options for {@link Client.users}. */ +export interface UserQuery { + /** Filter by user IDs */ + ids?: string[]; + /** Filter by user names */ + names?: string[]; +} + +/** Pagination/filter options for {@link Client.users}. */ +export interface ListUsersOptions { + /** Maximum number of users to return */ + first?: number; + /** Page token for pagination */ + after?: string; + /** Query filter for users */ + query?: UserQuery; +} + +/** Response shape for {@link Client.users}. */ +export interface ListUsersResponse { + users: User[]; + nextPageToken: string | null; + totalCount: number; +} + +/** Input for {@link Client.createUser}. */ +export interface CreateUserInput { + /** The user's name (typically email) */ + name: string; + /** The user's password. If omitted, the user is created without a password (cannot log in with any password). */ + password?: string; + /** Whether the user is disabled */ + disabled?: boolean; +} + +/** Input for {@link Client.updateUser}. */ +export interface UpdateUserInput { + /** The user's ID */ + id: string; + /** New name for the user */ + name?: string; + /** New password for the user. Cannot be used with clearPassword. */ + password?: string; + /** If true, remove the user's password. Cannot be used with password. */ + clearPassword?: boolean; + /** New disabled status for the user */ + disabled?: boolean; +} + +/** Input for {@link Client.sendPasswordResetEmail}. */ +export interface SendPasswordResetEmailInput { + /** The ID of the user */ + userId: string; + /** The URI to redirect to after password reset */ + redirectUri: string; + /** The sender display name. Defaults to 'Tailor Platform IdP'. */ + fromName?: string; + /** The email subject line. Defaults to the localized default subject. */ + subject?: string; +} + +/** Instance methods exposed by `tailor.idp.Client`. */ +export interface IdpClientInstance { + users(options?: ListUsersOptions): Promise; + user(userId: string): Promise; + userByName(name: string): Promise; + createUser(input: CreateUserInput): Promise; + updateUser(input: UpdateUserInput): Promise; + deleteUser(userId: string): Promise; + sendPasswordResetEmail(input: SendPasswordResetEmailInput): Promise; +} + +/** + * Constructor shape for `tailor.idp.Client`. + * @internal + */ +export interface IdpClientConstructor { + new (config: ClientConfig): IdpClientInstance; +} + +/** + * Platform API surface for `tailor.idp`. Describes the shape the platform + * runtime injects on `globalThis.tailor.idp`. + * @internal + */ +export interface TailorIdpAPI { + Client: IdpClientConstructor; +} + +/** + * IDP Client for user management operations. + * + * Wraps the platform-provided `tailor.idp.Client` and exposes the same surface. + */ +export class Client { + #impl: IdpClientInstance; + + constructor(config: ClientConfig) { + this.#impl = new (globalThis as { tailor: { idp: TailorIdpAPI } }).tailor.idp.Client(config); + } + + /** + * List users in the namespace with optional filtering and pagination. + * @param options - Pagination and filter options + * @returns Page of users with `nextPageToken` and `totalCount` + */ + users(options?: ListUsersOptions): Promise { + return this.#impl.users(options); + } + + /** + * Get a user by ID. + * @param userId - IDP user ID + * @returns The matching user + */ + user(userId: string): Promise { + return this.#impl.user(userId); + } + + /** + * Get a user by name. + * @param name - IDP user name + * @returns The matching user + */ + userByName(name: string): Promise { + return this.#impl.userByName(name); + } + + /** + * Create a new user. + * @param input - User attributes + * @returns The newly created user + */ + createUser(input: CreateUserInput): Promise { + return this.#impl.createUser(input); + } + + /** + * Update an existing user. + * @param input - User ID plus attributes to update + * @returns The updated user + */ + updateUser(input: UpdateUserInput): Promise { + return this.#impl.updateUser(input); + } + + /** + * Delete a user by ID. + * @param userId - IDP user ID + * @returns `true` when the user was deleted + */ + deleteUser(userId: string): Promise { + return this.#impl.deleteUser(userId); + } + + /** + * Send a password reset email to a user. + * @param input - Target user ID and redirect URI + * @returns `true` when the email was queued + */ + sendPasswordResetEmail(input: SendPasswordResetEmailInput): Promise { + return this.#impl.sendPasswordResetEmail(input); + } +} diff --git a/packages/sdk/src/runtime/index.ts b/packages/sdk/src/runtime/index.ts new file mode 100644 index 000000000..e7e9c7a5c --- /dev/null +++ b/packages/sdk/src/runtime/index.ts @@ -0,0 +1,77 @@ +/** + * Typed wrappers for the Tailor Platform Function runtime APIs. + * + * Each namespace mirrors the corresponding `tailor.*` (or `tailordb.file`) + * surface that the platform runtime exposes globally. The aggregate + * `TailorRuntime` / `TailordbRuntime` types compose those per-service shapes + * into the full top-level runtime objects, and are reused by the ambient + * `var tailor` / `var tailordb` declarations in `./globals`. + * @example + * import { iconv, secretmanager, idp, workflow, file } from "@tailor-platform/sdk/runtime"; + * + * const utf8 = iconv.convert(sjisBuffer, "Shift_JIS", "UTF-8"); + * const secret = await secretmanager.getSecret("my-vault", "API_KEY"); + * const client = new idp.Client({ namespace: "my-namespace" }); + */ + +import type { TailorAuthconnectionAPI } from "./authconnection"; +import type { TailorContextAPI } from "./context"; +import type { TailorDBFileAPI } from "./file"; +import type { TailorIconvAPI } from "./iconv"; +import type { TailorIdpAPI } from "./idp"; +import type { TailorSecretmanagerAPI } from "./secretmanager"; +import type { TailorWorkflowAPI } from "./workflow"; + +export * as iconv from "./iconv"; +export * as secretmanager from "./secretmanager"; +export * as authconnection from "./authconnection"; +export * as idp from "./idp"; +export * as workflow from "./workflow"; +export * as context from "./context"; +export * as file from "./file"; + +/** SQL command type recorded on a {@link TailordbQueryResult}. */ +export type TailordbCommandType = + | "INSERT" + | "DELETE" + | "UPDATE" + | "SELECT" + | "MOVE" + | "FETCH" + | "COPY" + | "CREATE"; + +/** Result of a single `queryObject` call against the TailorDB driver. */ +export interface TailordbQueryResult { + rows: T[]; + command: TailordbCommandType; + rowCount: number; +} + +/** Instance methods exposed by `tailordb.Client`. */ +export interface TailordbClientInstance { + connect(): Promise; + end(): Promise; + queryObject(sql: string, args?: readonly unknown[]): Promise>; +} + +/** Constructor shape for `tailordb.Client`. */ +export interface TailordbClientConstructor { + new (config: { namespace: string }): TailordbClientInstance; +} + +/** Top-level `tailor` runtime object. */ +export interface TailorRuntime { + secretmanager: TailorSecretmanagerAPI; + authconnection: TailorAuthconnectionAPI; + iconv: TailorIconvAPI; + idp: TailorIdpAPI; + workflow: TailorWorkflowAPI; + context: TailorContextAPI; +} + +/** Top-level `tailordb` runtime object. */ +export interface TailordbRuntime { + Client: TailordbClientConstructor; + file: TailorDBFileAPI; +} diff --git a/packages/sdk/src/runtime/secretmanager.test.ts b/packages/sdk/src/runtime/secretmanager.test.ts new file mode 100644 index 000000000..4c787ae11 --- /dev/null +++ b/packages/sdk/src/runtime/secretmanager.test.ts @@ -0,0 +1,51 @@ +/** + * Tests for `@tailor-platform/sdk/runtime/secretmanager` typed wrappers. + */ +import { afterEach, beforeEach, describe, expect, expectTypeOf, test } from "vitest"; +import * as secretmanager from "@/runtime/secretmanager"; +import { cleanupMocks, injectMocks, secretmanagerMock } from "@/vitest/mock"; + +describe("@tailor-platform/sdk/runtime/secretmanager", () => { + beforeEach(() => { + injectMocks(globalThis); + secretmanagerMock.reset(); + }); + + afterEach(() => { + cleanupMocks(globalThis); + }); + + test("getSecret forwards to global and returns Promise", async () => { + secretmanagerMock.setSecrets({ vault: { API_KEY: "sk-123" } }); + + const result = secretmanager.getSecret("vault", "API_KEY"); + + expectTypeOf(result).toEqualTypeOf>(); + await expect(result).resolves.toBe("sk-123"); + expect(secretmanagerMock.calls).toEqual([ + { method: "getSecret", vault: "vault", name: "API_KEY" }, + ]); + }); + + test("getSecret returns undefined for missing secret", async () => { + await expect(secretmanager.getSecret("vault", "NOPE")).resolves.toBeUndefined(); + }); + + test("getSecrets narrows record key to const tuple union", async () => { + secretmanagerMock.setSecrets({ v: { a: "1", b: "2" } }); + + const result = secretmanager.getSecrets("v", ["a", "b"] as const); + + expectTypeOf(result).toEqualTypeOf>>>(); + await expect(result).resolves.toEqual({ a: "1", b: "2" }); + expect(secretmanagerMock.calls).toEqual([ + { method: "getSecrets", vault: "v", names: ["a", "b"] }, + ]); + }); + + test("getSecrets omits missing names", async () => { + secretmanagerMock.setSecrets({ v: { a: "1" } }); + + await expect(secretmanager.getSecrets("v", ["a", "b"] as const)).resolves.toEqual({ a: "1" }); + }); +}); diff --git a/packages/sdk/src/runtime/secretmanager.ts b/packages/sdk/src/runtime/secretmanager.ts new file mode 100644 index 000000000..c203d66eb --- /dev/null +++ b/packages/sdk/src/runtime/secretmanager.ts @@ -0,0 +1,60 @@ +/** + * Secret manager utilities. + * + * Thin typed wrapper around the platform-provided `tailor.secretmanager` runtime API. + * At runtime this delegates to `globalThis.tailor.secretmanager`. Use + * `secretmanagerMock` from `@tailor-platform/sdk/vitest` to mock these calls + * in unit tests. + * @example + * import { secretmanager } from "@tailor-platform/sdk/runtime"; + * + * const apiKey = await secretmanager.getSecret("my-vault", "API_KEY"); + * const all = await secretmanager.getSecrets("my-vault", ["A", "B"] as const); + */ + +/** + * Platform API surface for `tailor.secretmanager`. Describes the shape the + * platform runtime injects on `globalThis.tailor.secretmanager`. + * + * Each method below is also re-exported as a top-level named export from this + * module so callers can either `import * as secretmanager from + * "@tailor-platform/sdk/runtime/secretmanager"` or pick individual methods. + */ +export interface TailorSecretmanagerAPI { + /** + * Returns multiple secrets from a vault. Missing names are omitted from the result. + * @param vault - Vault name + * @param names - Secret names to fetch (use `as const` to narrow the result key) + * @returns Partial record keyed by the requested names + */ + getSecrets( + vault: string, + names: T, + ): Promise>>; + + /** + * Returns a single secret from a vault, or `undefined` when missing. + * @param vault - Vault name + * @param name - Secret name + * @returns The secret value, or `undefined` if not present + */ + getSecret(vault: string, name: string): Promise; +} + +const api = (): TailorSecretmanagerAPI => + (globalThis as { tailor: { secretmanager: TailorSecretmanagerAPI } }).tailor.secretmanager; + +/** + * See {@link TailorSecretmanagerAPI.getSecrets}. + * @param args - Forwarded to {@link TailorSecretmanagerAPI.getSecrets} + * @returns Partial record keyed by the requested names + */ +export const getSecrets: TailorSecretmanagerAPI["getSecrets"] = (...args) => + api().getSecrets(...args); + +/** + * See {@link TailorSecretmanagerAPI.getSecret}. + * @param args - Forwarded to {@link TailorSecretmanagerAPI.getSecret} + * @returns The secret value, or `undefined` if not present + */ +export const getSecret: TailorSecretmanagerAPI["getSecret"] = (...args) => api().getSecret(...args); diff --git a/packages/sdk/src/runtime/workflow.test.ts b/packages/sdk/src/runtime/workflow.test.ts new file mode 100644 index 000000000..a87f6a497 --- /dev/null +++ b/packages/sdk/src/runtime/workflow.test.ts @@ -0,0 +1,72 @@ +/** + * Tests for `@tailor-platform/sdk/runtime/workflow` typed wrappers. + */ +import { afterEach, beforeEach, describe, expect, expectTypeOf, test } from "vitest"; +import * as workflow from "@/runtime/workflow"; +import { cleanupMocks, injectMocks, workflowMock } from "@/vitest/mock"; + +describe("@tailor-platform/sdk/runtime/workflow", () => { + beforeEach(() => { + injectMocks(globalThis); + workflowMock.reset(); + }); + + afterEach(() => { + cleanupMocks(globalThis); + }); + + test("triggerWorkflow forwards args and returns Promise", async () => { + workflowMock.setTriggerHandler("exec-42"); + + const promise = workflow.triggerWorkflow("my-workflow", { a: 1 }); + + expectTypeOf(promise).toEqualTypeOf>(); + await expect(promise).resolves.toBe("exec-42"); + expect(workflowMock.calls).toEqual([ + { method: "triggerWorkflow", args: ["my-workflow", { a: 1 }, undefined] }, + ]); + }); + + test("triggerWorkflow forwards options", async () => { + await workflow.triggerWorkflow( + "my-workflow", + { a: 1 }, + { + authInvoker: { namespace: "ns", machineUserName: "mu" }, + }, + ); + + expect(workflowMock.calls[0]?.args[2]).toEqual({ + authInvoker: { namespace: "ns", machineUserName: "mu" }, + }); + }); + + test("triggerJobFunction forwards and returns enqueued result", () => { + workflowMock.enqueueResult({ ok: true }); + + const result = workflow.triggerJobFunction("my-job", { id: 1 }); + + expect(result).toEqual({ ok: true }); + expect(workflowMock.triggeredJobs).toEqual([{ jobName: "my-job", args: { id: 1 } }]); + }); + + test("wait records the call and returns the configured result", () => { + workflowMock.setWaitHandler({ resumed: true }); + + const result = workflow.wait("key-1", { p: 1 }); + + expect(result).toEqual({ resumed: true }); + expect(workflowMock.calls).toEqual([{ method: "wait", args: ["key-1", { p: 1 }] }]); + }); + + test("resolve records the call without invoking the callback", async () => { + let invoked = false; + await workflow.resolve("exec-1", "key-1", () => { + invoked = true; + }); + + expect(invoked).toBe(false); + expect(workflowMock.calls).toHaveLength(1); + expect(workflowMock.calls[0]?.method).toBe("resolve"); + }); +}); diff --git a/packages/sdk/src/runtime/workflow.ts b/packages/sdk/src/runtime/workflow.ts new file mode 100644 index 000000000..70e266aab --- /dev/null +++ b/packages/sdk/src/runtime/workflow.ts @@ -0,0 +1,111 @@ +/** + * Workflow utilities. + * + * Thin typed wrapper around the platform-provided `tailor.workflow` runtime API. + * At runtime this delegates to `globalThis.tailor.workflow`. Use `workflowMock` + * from `@tailor-platform/sdk/vitest` to mock these calls in unit tests. + * @example + * import { workflow } from "@tailor-platform/sdk/runtime"; + * + * const executionId = await workflow.triggerWorkflow("myWorkflow", { data: "value" }); + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** + * Specifies the machine user that should be used to execute the workflow. + * This allows workflows to run with specific authentication context. + */ +export interface AuthInvoker { + /** The namespace where the machine user is defined */ + namespace: string; + /** The name of the machine user to use for workflow execution */ + machineUserName: string; +} + +/** Options for {@link triggerWorkflow}. */ +export interface TriggerWorkflowOptions { + /** Optional authentication invoker to specify which machine user should execute the workflow */ + authInvoker?: AuthInvoker; +} + +/** + * Platform API surface for `tailor.workflow`. Describes the shape the platform + * runtime injects on `globalThis.tailor.workflow`. + * + * Each method below is also re-exported as a top-level named export from this + * module so callers can either `import * as workflow from + * "@tailor-platform/sdk/runtime/workflow"` or pick individual methods. + */ +export interface TailorWorkflowAPI { + /** + * Triggers a workflow and returns its execution ID. + * @param workflowName - Workflow name as defined in tailor.config + * @param args - Arguments forwarded to the workflow's main job + * @param options - Optional trigger options (e.g. `authInvoker`) + * @returns The execution ID of the triggered workflow + */ + triggerWorkflow( + workflowName: string, + args?: any, + options?: TriggerWorkflowOptions, + ): Promise; + + /** + * Triggers a job function and returns its result. + * @param jobName - Job name as defined in the workflow + * @param args - Arguments forwarded to the job + * @returns The job's return value + */ + triggerJobFunction(jobName: string, args?: any): any; + + /** + * Suspends the current workflow execution and waits for an external signal to resume. + * @param key - Wait point key + * @param payload - Optional payload to record with the wait point + * @returns The payload supplied by the corresponding `resolve` call + */ + wait(key: string, payload?: any): any; + + /** + * Resolves a waiting workflow execution, causing it to resume. + * @param executionId - The execution to resume + * @param key - Wait point key to resolve + * @param callback - Callback receiving the wait payload; its return value is forwarded to `wait` + * @returns A promise that resolves once the resolve has been recorded + */ + resolve(executionId: string, key: string, callback: (waitPayload: any) => any): Promise; +} + +const api = (): TailorWorkflowAPI => + (globalThis as { tailor: { workflow: TailorWorkflowAPI } }).tailor.workflow; + +/** + * See {@link TailorWorkflowAPI.triggerWorkflow}. + * @param args - Forwarded to {@link TailorWorkflowAPI.triggerWorkflow} + * @returns The execution ID of the triggered workflow + */ +export const triggerWorkflow: TailorWorkflowAPI["triggerWorkflow"] = (...args) => + api().triggerWorkflow(...args); + +/** + * See {@link TailorWorkflowAPI.triggerJobFunction}. + * @param args - Forwarded to {@link TailorWorkflowAPI.triggerJobFunction} + * @returns The job's return value + */ +export const triggerJobFunction: TailorWorkflowAPI["triggerJobFunction"] = (...args) => + api().triggerJobFunction(...args); + +/** + * See {@link TailorWorkflowAPI.wait}. + * @param args - Forwarded to {@link TailorWorkflowAPI.wait} + * @returns The payload supplied by the corresponding `resolve` call + */ +export const wait: TailorWorkflowAPI["wait"] = (...args) => api().wait(...args); + +/** + * See {@link TailorWorkflowAPI.resolve}. + * @param args - Forwarded to {@link TailorWorkflowAPI.resolve} + * @returns A promise that resolves once the resolve has been recorded + */ +export const resolve: TailorWorkflowAPI["resolve"] = (...args) => api().resolve(...args); diff --git a/packages/sdk/src/utils/test/mock.ts b/packages/sdk/src/utils/test/mock.ts index cb52d0a74..0150a476c 100644 --- a/packages/sdk/src/utils/test/mock.ts +++ b/packages/sdk/src/utils/test/mock.ts @@ -1,5 +1,6 @@ import * as path from "node:path"; import { pathToFileURL } from "node:url"; +import type { ContextInvoker } from "@/runtime/context"; import type { TailorInvoker } from "@/types/user"; type MainFunction = (args: Record) => unknown | Promise; @@ -34,7 +35,7 @@ interface TailordbGlobal { ) => Promise; }; context: { - getInvoker: () => tailor.context.Invoker | null; + getInvoker: () => ContextInvoker | null; }; }; } @@ -136,7 +137,7 @@ export function setupWorkflowMock(handler: JobHandler): { * @param invoker - The `TailorInvoker` value to return, or `null` for anonymous. */ export function setupInvokerMock(invoker: TailorInvoker): void { - const raw: tailor.context.Invoker | null = invoker + const raw: ContextInvoker | null = invoker ? { id: invoker.id, type: invoker.type, diff --git a/packages/sdk/src/vitest/index.ts b/packages/sdk/src/vitest/index.ts index 70a89b914..abc3479cb 100644 --- a/packages/sdk/src/vitest/index.ts +++ b/packages/sdk/src/vitest/index.ts @@ -22,8 +22,8 @@ import type { Plugin } from "vitest/config"; * * 3. **Platform API mocks** (environment) — All platform APIs are auto-injected with * control objects: `tailordbMock`, `workflowMock`, `secretmanagerMock`, - * `authconnectionMock`, `idpMock`, `fileMock`, `iconvMock`. Each provides response - * configuration, call recording, and reset. + * `authconnectionMock`, `idpMock`, `fileMock`, `iconvMock`. Each + * provides response configuration, call recording, and reset. * * 4. **Environment resolution** — Rewrites `environment: "tailor-runtime"` to the * absolute path of the bundled environment module via the config hook. diff --git a/packages/sdk/src/vitest/mock-types.test.ts b/packages/sdk/src/vitest/mock-types.test.ts index dce846a53..ce6c1778b 100644 --- a/packages/sdk/src/vitest/mock-types.test.ts +++ b/packages/sdk/src/vitest/mock-types.test.ts @@ -1,18 +1,19 @@ /** * Type-level tests verifying that the mock-injected globals expose the - * concrete signatures declared by `@tailor-platform/function-types`. + * concrete signatures declared by `@tailor-platform/sdk/runtime/globals`. * * Each test asserts a concrete return type (or call shape) — bare * `expectTypeOf(x).toEqualTypeOf()` self-comparisons are tautological * and are intentionally omitted because they would always pass. */ +import "@/runtime/globals"; import { afterAll, beforeAll, describe, expectTypeOf, test } from "vitest"; import { injectMocks, cleanupMocks } from "./mock"; beforeAll(() => injectMocks(globalThis)); afterAll(() => cleanupMocks(globalThis)); -describe("mock types match @tailor-platform/function-types", () => { +describe("mock types match @tailor-platform/sdk/runtime/globals", () => { describe("tailor.secretmanager", () => { test("getSecrets returns Promise>>", () => { expectTypeOf(tailor.secretmanager.getSecrets("vault", ["a", "b"] as const)).toEqualTypeOf< diff --git a/packages/sdk/src/vitest/mock.test.ts b/packages/sdk/src/vitest/mock.test.ts index 2bfccb8b1..b53b0c943 100644 --- a/packages/sdk/src/vitest/mock.test.ts +++ b/packages/sdk/src/vitest/mock.test.ts @@ -406,28 +406,36 @@ describe("mock", () => { expect(fileMock.calls).toHaveLength(0); }); - test("openDownloadStream with Uint8Array enqueued yields single chunk", async () => { - // Uint8Array is iterable as numbers; the mock must wrap it as one - // binary chunk instead of iterating byte-by-byte. - const bytes = new Uint8Array([1, 2, 3]); - fileMock.enqueueResult(bytes); + test("openDownloadStream rejects raw bytes to guide callers to structured chunks", async () => { + fileMock.enqueueResult(new Uint8Array([1, 2, 3])); + await expect( + (globalThis as any).tailordb.file.openDownloadStream("ns", "T", "f", "r"), + ).rejects.toThrow(/iterable of StreamValue items/); + }); + + test("openDownloadStream rejects non-StreamValue elements yielded by the iterable", async () => { + // Uint8Array[] is iterable but its elements aren't StreamValue items. + fileMock.enqueueResult([new Uint8Array([1]), new Uint8Array([2])]); const stream = await (globalThis as any).tailordb.file.openDownloadStream( "ns", "T", "f", "r", ); - const chunks: unknown[] = []; - for await (const chunk of stream) chunks.push(chunk); - expect(chunks).toHaveLength(1); - expect(chunks[0]).toBeInstanceOf(Uint8Array); - expect(chunks[0]).toEqual(bytes); + await expect(stream.next()).rejects.toThrow(/StreamValue/); }); - test("openDownloadStream with array of Uint8Array yields chunks in order", async () => { - const a = new Uint8Array([1, 2]); - const b = new Uint8Array([3, 4]); - fileMock.enqueueResult([a, b]); + test("openDownloadStream yields the enqueued StreamValue sequence", async () => { + const bytes = new Uint8Array([1, 2, 3]); + const sequence = [ + { + type: "metadata" as const, + metadata: { contentType: "application/octet-stream", fileSize: 3, sha256sum: "h" }, + }, + { type: "chunk" as const, data: bytes, position: 0 }, + { type: "complete" as const }, + ]; + fileMock.enqueueResult(sequence); const stream = await (globalThis as any).tailordb.file.openDownloadStream( "ns", "T", @@ -436,7 +444,7 @@ describe("mock", () => { ); const chunks: unknown[] = []; for await (const chunk of stream) chunks.push(chunk); - expect(chunks).toEqual([a, b]); + expect(chunks).toEqual(sequence); }); test("default fallback is cloned so test mutations cannot leak across tests", async () => { diff --git a/packages/sdk/src/vitest/mock.ts b/packages/sdk/src/vitest/mock.ts index a1f0a21b7..911653bf5 100644 --- a/packages/sdk/src/vitest/mock.ts +++ b/packages/sdk/src/vitest/mock.ts @@ -6,6 +6,10 @@ * responses and assert on recorded calls via the exported mock objects. */ +import type { ContextInvoker } from "../runtime/context"; +import type { TailorDBFileErrorCode } from "../runtime/file"; +import type { User as IdpUser } from "../runtime/idp"; + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -678,7 +682,10 @@ async function mockResolve( // Mock: tailor.context // --------------------------------------------------------------------------- -function mockGetInvoker(): tailor.context.Invoker | null { +// Stub-only injection. SDK consumers configure invokers at the body level +// (resolver/executor/workflow `.body()` `invoker` arg) or, for bundled tests, +// via `vi.spyOn(globalThis.tailor.context, "getInvoker")`. +function mockGetInvoker(): ContextInvoker | null { return null; } @@ -757,23 +764,23 @@ class MockIdpClient { first?: number; after?: string; query?: { ids?: string[]; names?: string[] }; - }): Promise<{ users: tailor.idp.User[]; nextPageToken: string | null; totalCount: number }> { + }): Promise<{ users: IdpUser[]; nextPageToken: string | null; totalCount: number }> { return resolveIdpCall("users", [options], this.#namespace) as Awaited< ReturnType >; } - async user(userId: string): Promise { - return resolveIdpCall("user", [userId], this.#namespace) as tailor.idp.User; + async user(userId: string): Promise { + return resolveIdpCall("user", [userId], this.#namespace) as IdpUser; } - async userByName(name: string): Promise { - return resolveIdpCall("userByName", [name], this.#namespace) as tailor.idp.User; + async userByName(name: string): Promise { + return resolveIdpCall("userByName", [name], this.#namespace) as IdpUser; } async createUser(input: { name: string; password?: string; disabled?: boolean; - }): Promise { - return resolveIdpCall("createUser", [input], this.#namespace) as tailor.idp.User; + }): Promise { + return resolveIdpCall("createUser", [input], this.#namespace) as IdpUser; } async updateUser(input: { id: string; @@ -781,8 +788,8 @@ class MockIdpClient { password?: string; clearPassword?: boolean; disabled?: boolean; - }): Promise { - return resolveIdpCall("updateUser", [input], this.#namespace) as tailor.idp.User; + }): Promise { + return resolveIdpCall("updateUser", [input], this.#namespace) as IdpUser; } async deleteUser(userId: string): Promise { return resolveIdpCall("deleteUser", [userId], this.#namespace) as boolean; @@ -987,7 +994,7 @@ const mockTailordbFile = { ReturnType >; }, - openDownloadStream( + async openDownloadStream( namespace: string, typeName: string, fieldName: string, @@ -1000,7 +1007,7 @@ const mockTailordbFile = { fieldName, recordId, ); - return Promise.resolve(toFileStream(resolved)); + return toFileStream(resolved); }, }; @@ -1016,15 +1023,21 @@ function toFileStream(value: unknown): FileStream { ) { return value as FileStream; } - // Binary chunk shorthand: a single ArrayBuffer / TypedArray (e.g. Uint8Array) - // should be delivered as one chunk, not iterated as a sequence of numbers. - // Tests passing `[chunk1, chunk2]` continue to work via the iterable branch - // below. + // Guard against passing raw bytes directly: `Uint8Array` is iterable as + // numbers, which would silently yield byte values as chunks. The platform's + // stream protocol emits structured `StreamValue` items, so callers must + // enqueue an iterable of `StreamValue` instead. if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) { - return toFileStream([value]); + throw new TypeError( + "fileMock.openDownloadStream expects an iterable of StreamValue items " + + '(e.g. [{ type: "chunk", data, position }, { type: "complete" }]); ' + + "got raw bytes. Wrap the bytes in a structured chunk first.", + ); } // Iterable (array, sync iterator, etc.): wrap as a chunked async iterator - // so `fileMock.enqueueResult([chunk1, chunk2])` controls stream contents. + // so `fileMock.enqueueResult([{ type: "metadata", ... }, { type: "chunk", ... }, ...])` + // controls stream contents. The platform emits structured StreamValue items; + // tests should enqueue an iterable of StreamValue to mirror that contract. if ( value !== null && typeof value === "object" && @@ -1038,6 +1051,9 @@ function toFileStream(value: unknown): FileStream { const stream: FileStream = { async next() { const r = await inner.next(); + if (!r.done) { + assertStreamValue(r.value); + } return r.done ? { done: true as const, value: undefined } : r; }, async close() {}, @@ -1059,6 +1075,29 @@ function toFileStream(value: unknown): FileStream { return empty; } +function assertStreamValue(v: unknown): void { + if (v === null || typeof v !== "object") { + throw new TypeError( + 'fileMock.openDownloadStream expected a StreamValue item ({ type: "metadata" | "chunk" | "complete", ... }); ' + + `got ${typeof v === "object" ? "null" : typeof v}.`, + ); + } + // ArrayBuffer / TypedArray are objects but never valid StreamValue items. + if (v instanceof ArrayBuffer || ArrayBuffer.isView(v)) { + throw new TypeError( + "fileMock.openDownloadStream expected a StreamValue item, got raw bytes. " + + 'Wrap the bytes in a structured chunk first (e.g. { type: "chunk", data, position }).', + ); + } + const type = (v as { type?: unknown }).type; + if (type !== "metadata" && type !== "chunk" && type !== "complete") { + throw new TypeError( + 'fileMock.openDownloadStream expected a StreamValue item with type "metadata" | "chunk" | "complete"; ' + + `got ${JSON.stringify(type)}.`, + ); + } +} + // --------------------------------------------------------------------------- // Error class mocks // --------------------------------------------------------------------------- @@ -1101,10 +1140,10 @@ class TailorErrorMessageMock extends Error { } class TailorDBFileErrorMock extends Error { - code?: string; + code?: TailorDBFileErrorCode; override cause: unknown; - constructor(message: string, code?: string, cause?: unknown) { + constructor(message: string, code?: TailorDBFileErrorCode, cause?: unknown) { super(message); this.name = "TailorDBFileError"; this.code = code; diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json index e0fa2df81..b532c8053 100644 --- a/packages/sdk/tsconfig.json +++ b/packages/sdk/tsconfig.json @@ -9,8 +9,7 @@ "paths": { "@/*": ["./src/*"], "@tailor-proto/*": ["../tailor-proto/src/*"] - }, - "types": ["@tailor-platform/function-types"] + } }, "include": [ "./src/**/*.ts", @@ -21,5 +20,5 @@ "./vitest.config.ts", "./zinfer.config.ts" ], - "exclude": ["node_modules", "dist", "e2e/fixtures"] + "exclude": ["node_modules", "dist", "e2e/fixtures", "**/__test_fixtures__/**"] } diff --git a/packages/sdk/tsdown.config.ts b/packages/sdk/tsdown.config.ts index 2a37b1b2f..1ef0b7cc6 100644 --- a/packages/sdk/tsdown.config.ts +++ b/packages/sdk/tsdown.config.ts @@ -1,7 +1,33 @@ +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; import Sonda from "sonda/rolldown"; import { defineConfig, type TsdownPluginOption } from "tsdown"; import { loadYamlText } from "./scripts/yaml-text-plugin.mjs"; +// `banner.dts` injects the triple-slash into every emitted d.mts. Keep it only +// on `configure/index.d.mts` (the `@tailor-platform/sdk` main entry) so that +// the legacy ambient globals stay active for that import path through v2.0. +// Strip it from every other `.d.mts` so subpath imports +// (`@tailor-platform/sdk/runtime`, `/vitest`, /plugin`, etc.) stay self-contained. +function stripBannerExceptConfigureEntry(outDir: string): void { + const pattern = /^\/\/\/ \r?\n/; + const root = path.resolve(outDir); + const keep = path.join(root, "configure", "index.d.mts"); + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.isFile() && entry.name.endsWith(".d.mts") && full !== keep) { + const content = readFileSync(full, "utf-8"); + const cleaned = content.replace(pattern, ""); + if (cleaned !== content) writeFileSync(full, cleaned, "utf-8"); + } + } + }; + walk(root); +} + function yamlText() { return { name: "yaml-text", @@ -43,6 +69,15 @@ export default defineConfig({ "src/vitest/index.ts", "src/vitest/environment.ts", "src/vitest/setup.ts", + "src/runtime/index.ts", + "src/runtime/globals.ts", + "src/runtime/iconv.ts", + "src/runtime/secretmanager.ts", + "src/runtime/authconnection.ts", + "src/runtime/idp.ts", + "src/runtime/workflow.ts", + "src/runtime/context.ts", + "src/runtime/file.ts", ], format: ["esm"], target: "node22", @@ -56,11 +91,15 @@ export default defineConfig({ js: ".mjs", dts: ".d.mts", }), + // Remove in v2.0. banner: { - dts: '/// ', + dts: '/// ', }, // peer dependencies: prevent bundling, resolve at runtime deps: { neverBundle: ["vite", "vitest"] }, sourcemap: true, plugins, + onSuccess: (config) => { + stripBannerExceptConfigureEntry(config.outDir); + }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 664a279eb..e116af0f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -419,9 +419,6 @@ importers: '@tailor-platform/function-kysely-tailordb': specifier: 0.1.3 version: 0.1.3(kysely@0.28.17) - '@tailor-platform/function-types': - specifier: 0.8.5 - version: 0.8.5 '@toiroakr/lines-db': specifier: 0.9.2 version: 0.9.2(valibot@1.1.0(typescript@5.9.3)) @@ -2519,9 +2516,6 @@ packages: peerDependencies: kysely: '>= 0.24.0 < 1' - '@tailor-platform/function-types@0.8.5': - resolution: {integrity: sha512-D+6ylw2QHtms0mFLsfLCyk3Jeb8kXk2Vb8OqkzHCR8hS7LAna77W2ppHTioCmFq0xr+2ypqNByIyQKJB0+bqOQ==} - '@toiroakr/lines-db@0.9.2': resolution: {integrity: sha512-8ilJFrDcz1aaQb6inA6cQXovu4F3wRHFvG2sNXYlbtwyOg8csJTSB/BP6EEfvWRqt8j/uRK71rI99baS9yKYvw==} hasBin: true @@ -6311,8 +6305,6 @@ snapshots: dependencies: kysely: 0.28.17 - '@tailor-platform/function-types@0.8.5': {} - '@toiroakr/lines-db@0.9.2(valibot@1.1.0(typescript@5.9.3))': dependencies: '@standard-schema/spec': 1.1.0 diff --git a/skills/tailor-sdk/SKILL.md b/skills/tailor-sdk/SKILL.md index ee1852d2a..7d1dcdcf2 100644 --- a/skills/tailor-sdk/SKILL.md +++ b/skills/tailor-sdk/SKILL.md @@ -17,6 +17,7 @@ Use these files as the single source of truth: - `node_modules/@tailor-platform/sdk/docs/cli-reference.md` - `node_modules/@tailor-platform/sdk/docs/cli/*.md` - `node_modules/@tailor-platform/sdk/docs/testing.md` +- `node_modules/@tailor-platform/sdk/docs/runtime.md` ## Working Rules @@ -32,3 +33,4 @@ Use these files as the single source of truth: - Service details: `docs/services/*.md` - CLI commands: `docs/cli-reference.md` and `docs/cli/*.md` - Testing patterns: `docs/testing.md` +- Runtime API wrappers (`tailor.iconv`, `tailor.secretmanager`, `tailor.idp`, `tailor.workflow`, `tailor.context`, `tailor.authconnection`, `tailordb.file`): `docs/runtime.md`