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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 14 additions & 22 deletions docs/prompts/2026-01-26-refactoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
This proposal responds to Merlijn's review and issue #20 (type duplication). It focuses on:

- Removing custom Uppy + React hooks in favor of official Uppy patterns.
- Reusing existing Transloadit SDKs where it makes sense.
- Reusing existing Transloadit SDKs where it makes sense, without adding `@transloadit/node`.
- Reducing type duplication by leaning on Zod schemas and convex-helpers.
- Keeping the current component stable while introducing a cleaner, long-term path.

Expand All @@ -19,8 +19,8 @@ This proposal responds to Merlijn's review and issue #20 (type duplication). It
- Uppy 5.0 ships official React hooks and recommends using them via `UppyContextProvider`.
- `@uppy/transloadit` is the canonical plugin; it uses Tus internally and supports
`assemblyOptions` as a function that can call a backend for signatures.
- Convex allows marking external packages for Node actions, so a heavier SDK like `transloadit`
can be used server-side without client bundle impact.
- Convex allows marking external packages for Node actions, but we will avoid adding
`@transloadit/node` to keep setup minimal.
- `convex-helpers` provides Zod-based function argument validation and optional `zodToConvex`
helpers for schema definitions, with caveats.

Expand All @@ -43,20 +43,11 @@ This lets us remove:

We can keep a small helper that wires `assemblyOptions()` to a Convex HTTP endpoint or action.

### 2) Reuse the `transloadit` Node SDK server-side
### 2) Keep the lightweight API client (no `@transloadit/node`)

Likely feasible, but optional.

Convex Node actions can import external packages. That means we could:
- Use the `transloadit` SDK inside `createAssembly` and `refreshAssembly` actions.
- Avoid re-implementing API logic and edge-case handling.

Tradeoffs:
- Adds dependency weight.
- Need to verify ESM/CJS interop.
- Might require a Convex `convex.json` config change or explicit guidance for adopters.

Alternative: keep the lightweight `fetch` + `@transloadit/utils` approach to avoid dependency risk.
We will keep the current `fetch` + `@transloadit/utils` approach. It avoids extra setup
(`convex.json` externalPackages) and keeps the component light. If we later discover
edge cases the SDK handles significantly better, we can revisit with evidence.

### 3) Type duplication (issue #20)

Expand All @@ -71,6 +62,10 @@ We can reduce duplication without fully replacing Convex validators:
This aligns with Convex guidance: Zod is great for args; use `zodToConvex` for DB schemas only
when the tradeoffs are acceptable.

**Decision for now:** keep a single Convex schema source in `src/shared/schemas.ts`. We can revisit
convex-helpers later for *function args only* if we want richer validation, but it does not replace
DB schemas and adds another dependency.

## Proposed direction (phased)

### Phase 0: Remove custom hooks + expose the official path (short)
Expand All @@ -91,11 +86,9 @@ when the tradeoffs are acceptable.
- Remove remaining Uppy-specific helpers from `@transloadit/convex/react`.
- Keep only status/results helpers (or remove the React entry entirely if unnecessary).

### Phase 3: Optional server SDK adoption (medium)
### Phase 3: Optional server SDK adoption (not planned)

Likely **not** needed. Keep `fetch` + `@transloadit/utils` unless we can demonstrate:
- clear edge-case improvements over the current implementation, or
- significant DX improvements without extra complexity.
No server SDK adoption planned. Stick with `fetch` + `@transloadit/utils`.

### Phase 4: Type cleanup (medium)

Expand All @@ -116,8 +109,7 @@ Likely **not** needed. Keep `fetch` + `@transloadit/utils` unless we can demonst
1. Should `@transloadit/convex/react` remain as a small status/results hook library,
or be removed entirely in favor of Uppy React hooks?
2. `createAssemblyOptions` should support both templates and inline steps.
3. Avoid requiring `convex.json` changes unless we adopt the `transloadit` SDK for
clear edge-case wins.
3. Avoid requiring `convex.json` changes by staying off `@transloadit/node`.

## Success criteria

Expand Down
162 changes: 43 additions & 119 deletions src/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,31 @@
import type { AssemblyStatus } from "@transloadit/zod/v3/assemblyStatus";
import type { AssemblyInstructionsInput } from "@transloadit/zod/v3/template";
import { actionGeneric, mutationGeneric, queryGeneric } from "convex/server";
import { type Infer, v } from "convex/values";
import { v } from "convex/values";
import type { ComponentApi } from "../component/_generated/component.ts";
import {
type AssemblyResponse,
type AssemblyResultResponse,
type CreateAssemblyArgs,
vAssemblyIdArgs,
vAssemblyResponse,
vAssemblyResultResponse,
vCreateAssemblyArgs,
vCreateAssemblyReturn,
vListAlbumResultsArgs,
vListAssembliesArgs,
vListResultsArgs,
vPurgeAlbumArgs,
vPurgeAlbumResponse,
vQueueWebhookResponse,
vStoreAssemblyMetadataArgs,
vWebhookActionArgs,
vWebhookResponse,
} from "../shared/schemas.ts";
import type { RunActionCtx, RunMutationCtx, RunQueryCtx } from "./types.ts";

export { vAssemblyResponse, vAssemblyResultResponse, vCreateAssemblyArgs };

export {
assemblyStatusErrCodeSchema,
assemblyStatusOkCodeSchema,
Expand All @@ -20,11 +41,6 @@ export {
isAssemblyTerminalOk,
isAssemblyTerminalOkStatus,
} from "@transloadit/zod/v3/assemblyStatus";
export type {
ParsedWebhookRequest,
VerifiedWebhookRequest,
WebhookActionArgs,
} from "../component/apiUtils.ts";
export {
buildWebhookQueueArgs,
handleWebhookRequest,
Expand Down Expand Up @@ -61,6 +77,11 @@ export {
getResultOriginalKey,
getResultUrl,
} from "../shared/resultUtils.ts";
export type {
ParsedWebhookRequest,
VerifiedWebhookRequest,
WebhookActionArgs,
} from "../shared/schemas.ts";
export type {
TusMetadataOptions,
TusUploadConfig,
Expand All @@ -85,56 +106,7 @@ function requireEnv(names: string[]): string {
throw new Error(`Missing ${names.join(" or ")} environment variable`);
}

export const vAssemblyResponse = v.object({
_id: v.string(),
_creationTime: v.number(),
assemblyId: v.string(),
status: v.optional(v.string()),
ok: v.optional(v.string()),
message: v.optional(v.string()),
templateId: v.optional(v.string()),
notifyUrl: v.optional(v.string()),
numExpectedUploadFiles: v.optional(v.number()),
fields: v.optional(v.record(v.string(), v.any())),
uploads: v.optional(v.array(v.any())),
results: v.optional(v.record(v.string(), v.array(v.any()))),
error: v.optional(v.any()),
raw: v.optional(v.any()),
createdAt: v.number(),
updatedAt: v.number(),
userId: v.optional(v.string()),
});

export type AssemblyResponse = Infer<typeof vAssemblyResponse>;

export const vAssemblyResultResponse = v.object({
_id: v.string(),
_creationTime: v.number(),
assemblyId: v.string(),
album: v.optional(v.string()),
userId: v.optional(v.string()),
stepName: v.string(),
resultId: v.optional(v.string()),
sslUrl: v.optional(v.string()),
name: v.optional(v.string()),
size: v.optional(v.number()),
mime: v.optional(v.string()),
raw: v.any(),
createdAt: v.number(),
});

export type AssemblyResultResponse = Infer<typeof vAssemblyResultResponse>;

export const vCreateAssemblyArgs = v.object({
templateId: v.optional(v.string()),
steps: v.optional(v.record(v.string(), v.any())),
fields: v.optional(v.record(v.string(), v.any())),
notifyUrl: v.optional(v.string()),
numExpectedUploadFiles: v.optional(v.number()),
expires: v.optional(v.string()),
additionalParams: v.optional(v.record(v.string(), v.any())),
userId: v.optional(v.string()),
});
export type { AssemblyResponse, AssemblyResultResponse, CreateAssemblyArgs };

/**
* @deprecated Prefer `makeTransloaditAPI` or `Transloadit` for new code.
Expand All @@ -158,10 +130,7 @@ export class TransloaditClient {
return new TransloaditClient(component, config);
}

async createAssembly(
ctx: RunActionCtx,
args: Infer<typeof vCreateAssemblyArgs>,
) {
async createAssembly(ctx: RunActionCtx, args: CreateAssemblyArgs) {
return ctx.runAction(this.component.lib.createAssembly, {
...args,
config: this.config,
Expand Down Expand Up @@ -266,10 +235,7 @@ export function makeTransloaditAPI(
return {
createAssembly: actionGeneric({
args: vCreateAssemblyArgs,
returns: v.object({
assemblyId: v.string(),
data: v.any(),
}),
returns: vCreateAssemblyReturn,
handler: async (ctx, args) => {
const resolvedConfig = resolveConfig();
return ctx.runAction(component.lib.createAssembly, {
Expand All @@ -279,17 +245,8 @@ export function makeTransloaditAPI(
},
}),
handleWebhook: actionGeneric({
args: {
payload: v.any(),
rawBody: v.optional(v.string()),
signature: v.optional(v.string()),
},
returns: v.object({
assemblyId: v.string(),
resultCount: v.number(),
ok: v.optional(v.string()),
status: v.optional(v.string()),
}),
args: vWebhookActionArgs,
returns: vWebhookResponse,
handler: async (ctx, args) => {
const resolvedConfig = resolveConfig();
return ctx.runAction(component.lib.handleWebhook, {
Expand All @@ -299,15 +256,8 @@ export function makeTransloaditAPI(
},
}),
queueWebhook: actionGeneric({
args: {
payload: v.any(),
rawBody: v.optional(v.string()),
signature: v.optional(v.string()),
},
returns: v.object({
assemblyId: v.string(),
queued: v.boolean(),
}),
args: vWebhookActionArgs,
returns: vQueueWebhookResponse,
handler: async (ctx, args) => {
const resolvedConfig = resolveConfig();
return ctx.runAction(component.lib.queueWebhook, {
Expand All @@ -317,13 +267,8 @@ export function makeTransloaditAPI(
},
}),
refreshAssembly: actionGeneric({
args: { assemblyId: v.string() },
returns: v.object({
assemblyId: v.string(),
resultCount: v.number(),
ok: v.optional(v.string()),
status: v.optional(v.string()),
}),
args: vAssemblyIdArgs,
returns: vWebhookResponse,
handler: async (ctx, args) => {
const resolvedConfig = resolveConfig();
return ctx.runAction(component.lib.refreshAssembly, {
Expand All @@ -333,63 +278,42 @@ export function makeTransloaditAPI(
},
}),
getAssemblyStatus: queryGeneric({
args: { assemblyId: v.string() },
args: vAssemblyIdArgs,
returns: v.union(vAssemblyResponse, v.null()),
handler: async (ctx, args) => {
return ctx.runQuery(component.lib.getAssemblyStatus, args);
},
}),
listAssemblies: queryGeneric({
args: {
status: v.optional(v.string()),
userId: v.optional(v.string()),
limit: v.optional(v.number()),
},
args: vListAssembliesArgs,
returns: v.array(vAssemblyResponse),
handler: async (ctx, args) => {
return ctx.runQuery(component.lib.listAssemblies, args);
},
}),
listResults: queryGeneric({
args: {
assemblyId: v.string(),
stepName: v.optional(v.string()),
limit: v.optional(v.number()),
},
args: vListResultsArgs,
returns: v.array(vAssemblyResultResponse),
handler: async (ctx, args) => {
return ctx.runQuery(component.lib.listResults, args);
},
}),
listAlbumResults: queryGeneric({
args: {
album: v.string(),
limit: v.optional(v.number()),
},
args: vListAlbumResultsArgs,
returns: v.array(vAssemblyResultResponse),
handler: async (ctx, args) => {
return ctx.runQuery(component.lib.listAlbumResults, args);
},
}),
purgeAlbum: mutationGeneric({
args: {
album: v.string(),
deleteAssemblies: v.optional(v.boolean()),
},
returns: v.object({
deletedResults: v.number(),
deletedAssemblies: v.number(),
}),
args: vPurgeAlbumArgs,
returns: vPurgeAlbumResponse,
handler: async (ctx, args) => {
return ctx.runMutation(component.lib.purgeAlbum, args);
},
}),
storeAssemblyMetadata: mutationGeneric({
args: {
assemblyId: v.string(),
userId: v.optional(v.string()),
fields: v.optional(v.record(v.string(), v.any())),
},
args: vStoreAssemblyMetadataArgs,
returns: v.union(vAssemblyResponse, v.null()),
handler: async (ctx, args) => {
return ctx.runMutation(component.lib.storeAssemblyMetadata, args);
Expand Down
Loading