Skip to content

Commit cd20593

Browse files
authored
refactor: centralize schemas (#21)
* refactor: centralize schemas * docs: note schema decision
1 parent 63283a1 commit cd20593

File tree

6 files changed

+380
-391
lines changed

6 files changed

+380
-391
lines changed

docs/prompts/2026-01-26-refactoring.md

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
This proposal responds to Merlijn's review and issue #20 (type duplication). It focuses on:
44

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

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

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

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

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

48-
Likely feasible, but optional.
49-
50-
Convex Node actions can import external packages. That means we could:
51-
- Use the `transloadit` SDK inside `createAssembly` and `refreshAssembly` actions.
52-
- Avoid re-implementing API logic and edge-case handling.
53-
54-
Tradeoffs:
55-
- Adds dependency weight.
56-
- Need to verify ESM/CJS interop.
57-
- Might require a Convex `convex.json` config change or explicit guidance for adopters.
58-
59-
Alternative: keep the lightweight `fetch` + `@transloadit/utils` approach to avoid dependency risk.
48+
We will keep the current `fetch` + `@transloadit/utils` approach. It avoids extra setup
49+
(`convex.json` externalPackages) and keeps the component light. If we later discover
50+
edge cases the SDK handles significantly better, we can revisit with evidence.
6051

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

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

65+
**Decision for now:** keep a single Convex schema source in `src/shared/schemas.ts`. We can revisit
66+
convex-helpers later for *function args only* if we want richer validation, but it does not replace
67+
DB schemas and adds another dependency.
68+
7469
## Proposed direction (phased)
7570

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

94-
### Phase 3: Optional server SDK adoption (medium)
89+
### Phase 3: Optional server SDK adoption (not planned)
9590

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

10093
### Phase 4: Type cleanup (medium)
10194

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

122114
## Success criteria
123115

src/client/index.ts

Lines changed: 43 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,31 @@
11
import type { AssemblyStatus } from "@transloadit/zod/v3/assemblyStatus";
22
import type { AssemblyInstructionsInput } from "@transloadit/zod/v3/template";
33
import { actionGeneric, mutationGeneric, queryGeneric } from "convex/server";
4-
import { type Infer, v } from "convex/values";
4+
import { v } from "convex/values";
55
import type { ComponentApi } from "../component/_generated/component.ts";
6+
import {
7+
type AssemblyResponse,
8+
type AssemblyResultResponse,
9+
type CreateAssemblyArgs,
10+
vAssemblyIdArgs,
11+
vAssemblyResponse,
12+
vAssemblyResultResponse,
13+
vCreateAssemblyArgs,
14+
vCreateAssemblyReturn,
15+
vListAlbumResultsArgs,
16+
vListAssembliesArgs,
17+
vListResultsArgs,
18+
vPurgeAlbumArgs,
19+
vPurgeAlbumResponse,
20+
vQueueWebhookResponse,
21+
vStoreAssemblyMetadataArgs,
22+
vWebhookActionArgs,
23+
vWebhookResponse,
24+
} from "../shared/schemas.ts";
625
import type { RunActionCtx, RunMutationCtx, RunQueryCtx } from "./types.ts";
726

27+
export { vAssemblyResponse, vAssemblyResultResponse, vCreateAssemblyArgs };
28+
829
export {
930
assemblyStatusErrCodeSchema,
1031
assemblyStatusOkCodeSchema,
@@ -20,11 +41,6 @@ export {
2041
isAssemblyTerminalOk,
2142
isAssemblyTerminalOkStatus,
2243
} from "@transloadit/zod/v3/assemblyStatus";
23-
export type {
24-
ParsedWebhookRequest,
25-
VerifiedWebhookRequest,
26-
WebhookActionArgs,
27-
} from "../component/apiUtils.ts";
2844
export {
2945
buildWebhookQueueArgs,
3046
handleWebhookRequest,
@@ -61,6 +77,11 @@ export {
6177
getResultOriginalKey,
6278
getResultUrl,
6379
} from "../shared/resultUtils.ts";
80+
export type {
81+
ParsedWebhookRequest,
82+
VerifiedWebhookRequest,
83+
WebhookActionArgs,
84+
} from "../shared/schemas.ts";
6485
export type {
6586
TusMetadataOptions,
6687
TusUploadConfig,
@@ -85,56 +106,7 @@ function requireEnv(names: string[]): string {
85106
throw new Error(`Missing ${names.join(" or ")} environment variable`);
86107
}
87108

88-
export const vAssemblyResponse = v.object({
89-
_id: v.string(),
90-
_creationTime: v.number(),
91-
assemblyId: v.string(),
92-
status: v.optional(v.string()),
93-
ok: v.optional(v.string()),
94-
message: v.optional(v.string()),
95-
templateId: v.optional(v.string()),
96-
notifyUrl: v.optional(v.string()),
97-
numExpectedUploadFiles: v.optional(v.number()),
98-
fields: v.optional(v.record(v.string(), v.any())),
99-
uploads: v.optional(v.array(v.any())),
100-
results: v.optional(v.record(v.string(), v.array(v.any()))),
101-
error: v.optional(v.any()),
102-
raw: v.optional(v.any()),
103-
createdAt: v.number(),
104-
updatedAt: v.number(),
105-
userId: v.optional(v.string()),
106-
});
107-
108-
export type AssemblyResponse = Infer<typeof vAssemblyResponse>;
109-
110-
export const vAssemblyResultResponse = v.object({
111-
_id: v.string(),
112-
_creationTime: v.number(),
113-
assemblyId: v.string(),
114-
album: v.optional(v.string()),
115-
userId: v.optional(v.string()),
116-
stepName: v.string(),
117-
resultId: v.optional(v.string()),
118-
sslUrl: v.optional(v.string()),
119-
name: v.optional(v.string()),
120-
size: v.optional(v.number()),
121-
mime: v.optional(v.string()),
122-
raw: v.any(),
123-
createdAt: v.number(),
124-
});
125-
126-
export type AssemblyResultResponse = Infer<typeof vAssemblyResultResponse>;
127-
128-
export const vCreateAssemblyArgs = v.object({
129-
templateId: v.optional(v.string()),
130-
steps: v.optional(v.record(v.string(), v.any())),
131-
fields: v.optional(v.record(v.string(), v.any())),
132-
notifyUrl: v.optional(v.string()),
133-
numExpectedUploadFiles: v.optional(v.number()),
134-
expires: v.optional(v.string()),
135-
additionalParams: v.optional(v.record(v.string(), v.any())),
136-
userId: v.optional(v.string()),
137-
});
109+
export type { AssemblyResponse, AssemblyResultResponse, CreateAssemblyArgs };
138110

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

161-
async createAssembly(
162-
ctx: RunActionCtx,
163-
args: Infer<typeof vCreateAssemblyArgs>,
164-
) {
133+
async createAssembly(ctx: RunActionCtx, args: CreateAssemblyArgs) {
165134
return ctx.runAction(this.component.lib.createAssembly, {
166135
...args,
167136
config: this.config,
@@ -266,10 +235,7 @@ export function makeTransloaditAPI(
266235
return {
267236
createAssembly: actionGeneric({
268237
args: vCreateAssemblyArgs,
269-
returns: v.object({
270-
assemblyId: v.string(),
271-
data: v.any(),
272-
}),
238+
returns: vCreateAssemblyReturn,
273239
handler: async (ctx, args) => {
274240
const resolvedConfig = resolveConfig();
275241
return ctx.runAction(component.lib.createAssembly, {
@@ -279,17 +245,8 @@ export function makeTransloaditAPI(
279245
},
280246
}),
281247
handleWebhook: actionGeneric({
282-
args: {
283-
payload: v.any(),
284-
rawBody: v.optional(v.string()),
285-
signature: v.optional(v.string()),
286-
},
287-
returns: v.object({
288-
assemblyId: v.string(),
289-
resultCount: v.number(),
290-
ok: v.optional(v.string()),
291-
status: v.optional(v.string()),
292-
}),
248+
args: vWebhookActionArgs,
249+
returns: vWebhookResponse,
293250
handler: async (ctx, args) => {
294251
const resolvedConfig = resolveConfig();
295252
return ctx.runAction(component.lib.handleWebhook, {
@@ -299,15 +256,8 @@ export function makeTransloaditAPI(
299256
},
300257
}),
301258
queueWebhook: actionGeneric({
302-
args: {
303-
payload: v.any(),
304-
rawBody: v.optional(v.string()),
305-
signature: v.optional(v.string()),
306-
},
307-
returns: v.object({
308-
assemblyId: v.string(),
309-
queued: v.boolean(),
310-
}),
259+
args: vWebhookActionArgs,
260+
returns: vQueueWebhookResponse,
311261
handler: async (ctx, args) => {
312262
const resolvedConfig = resolveConfig();
313263
return ctx.runAction(component.lib.queueWebhook, {
@@ -317,13 +267,8 @@ export function makeTransloaditAPI(
317267
},
318268
}),
319269
refreshAssembly: actionGeneric({
320-
args: { assemblyId: v.string() },
321-
returns: v.object({
322-
assemblyId: v.string(),
323-
resultCount: v.number(),
324-
ok: v.optional(v.string()),
325-
status: v.optional(v.string()),
326-
}),
270+
args: vAssemblyIdArgs,
271+
returns: vWebhookResponse,
327272
handler: async (ctx, args) => {
328273
const resolvedConfig = resolveConfig();
329274
return ctx.runAction(component.lib.refreshAssembly, {
@@ -333,63 +278,42 @@ export function makeTransloaditAPI(
333278
},
334279
}),
335280
getAssemblyStatus: queryGeneric({
336-
args: { assemblyId: v.string() },
281+
args: vAssemblyIdArgs,
337282
returns: v.union(vAssemblyResponse, v.null()),
338283
handler: async (ctx, args) => {
339284
return ctx.runQuery(component.lib.getAssemblyStatus, args);
340285
},
341286
}),
342287
listAssemblies: queryGeneric({
343-
args: {
344-
status: v.optional(v.string()),
345-
userId: v.optional(v.string()),
346-
limit: v.optional(v.number()),
347-
},
288+
args: vListAssembliesArgs,
348289
returns: v.array(vAssemblyResponse),
349290
handler: async (ctx, args) => {
350291
return ctx.runQuery(component.lib.listAssemblies, args);
351292
},
352293
}),
353294
listResults: queryGeneric({
354-
args: {
355-
assemblyId: v.string(),
356-
stepName: v.optional(v.string()),
357-
limit: v.optional(v.number()),
358-
},
295+
args: vListResultsArgs,
359296
returns: v.array(vAssemblyResultResponse),
360297
handler: async (ctx, args) => {
361298
return ctx.runQuery(component.lib.listResults, args);
362299
},
363300
}),
364301
listAlbumResults: queryGeneric({
365-
args: {
366-
album: v.string(),
367-
limit: v.optional(v.number()),
368-
},
302+
args: vListAlbumResultsArgs,
369303
returns: v.array(vAssemblyResultResponse),
370304
handler: async (ctx, args) => {
371305
return ctx.runQuery(component.lib.listAlbumResults, args);
372306
},
373307
}),
374308
purgeAlbum: mutationGeneric({
375-
args: {
376-
album: v.string(),
377-
deleteAssemblies: v.optional(v.boolean()),
378-
},
379-
returns: v.object({
380-
deletedResults: v.number(),
381-
deletedAssemblies: v.number(),
382-
}),
309+
args: vPurgeAlbumArgs,
310+
returns: vPurgeAlbumResponse,
383311
handler: async (ctx, args) => {
384312
return ctx.runMutation(component.lib.purgeAlbum, args);
385313
},
386314
}),
387315
storeAssemblyMetadata: mutationGeneric({
388-
args: {
389-
assemblyId: v.string(),
390-
userId: v.optional(v.string()),
391-
fields: v.optional(v.record(v.string(), v.any())),
392-
},
316+
args: vStoreAssemblyMetadataArgs,
393317
returns: v.union(vAssemblyResponse, v.null()),
394318
handler: async (ctx, args) => {
395319
return ctx.runMutation(component.lib.storeAssemblyMetadata, args);

0 commit comments

Comments
 (0)