@transloadit/convex 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +235 -0
  2. package/dist/client/index.d.ts +244 -0
  3. package/dist/client/index.d.ts.map +1 -0
  4. package/dist/client/index.js +196 -0
  5. package/dist/client/index.js.map +1 -0
  6. package/dist/client/types.d.ts +24 -0
  7. package/dist/client/types.d.ts.map +1 -0
  8. package/dist/client/types.js +2 -0
  9. package/dist/client/types.js.map +1 -0
  10. package/dist/component/_generated/api.d.ts +20 -0
  11. package/dist/component/_generated/api.d.ts.map +1 -0
  12. package/dist/component/_generated/api.js +15 -0
  13. package/dist/component/_generated/api.js.map +1 -0
  14. package/dist/component/_generated/component.d.ts +101 -0
  15. package/dist/component/_generated/component.d.ts.map +1 -0
  16. package/dist/component/_generated/component.js +11 -0
  17. package/dist/component/_generated/component.js.map +1 -0
  18. package/dist/component/_generated/dataModel.d.ts +16 -0
  19. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  20. package/dist/component/_generated/dataModel.js +11 -0
  21. package/dist/component/_generated/dataModel.js.map +1 -0
  22. package/dist/component/_generated/server.d.ts +31 -0
  23. package/dist/component/_generated/server.d.ts.map +1 -0
  24. package/dist/component/_generated/server.js +18 -0
  25. package/dist/component/_generated/server.js.map +1 -0
  26. package/dist/component/apiUtils.d.ts +31 -0
  27. package/dist/component/apiUtils.d.ts.map +1 -0
  28. package/dist/component/apiUtils.js +94 -0
  29. package/dist/component/apiUtils.js.map +1 -0
  30. package/dist/component/convex.config.d.ts +3 -0
  31. package/dist/component/convex.config.d.ts.map +1 -0
  32. package/dist/component/convex.config.js +3 -0
  33. package/dist/component/convex.config.js.map +1 -0
  34. package/dist/component/lib.d.ts +226 -0
  35. package/dist/component/lib.d.ts.map +1 -0
  36. package/dist/component/lib.js +383 -0
  37. package/dist/component/lib.js.map +1 -0
  38. package/dist/component/schema.d.ts +67 -0
  39. package/dist/component/schema.d.ts.map +1 -0
  40. package/dist/component/schema.js +45 -0
  41. package/dist/component/schema.js.map +1 -0
  42. package/dist/package.json +3 -0
  43. package/dist/react/index.d.ts +85 -0
  44. package/dist/react/index.d.ts.map +1 -0
  45. package/dist/react/index.js +163 -0
  46. package/dist/react/index.js.map +1 -0
  47. package/package.json +86 -0
  48. package/src/client/index.ts +260 -0
  49. package/src/client/types.ts +64 -0
  50. package/src/component/_generated/api.ts +32 -0
  51. package/src/component/_generated/component.ts +122 -0
  52. package/src/component/_generated/dataModel.ts +30 -0
  53. package/src/component/_generated/server.ts +72 -0
  54. package/src/component/apiUtils.test.ts +48 -0
  55. package/src/component/apiUtils.ts +156 -0
  56. package/src/component/convex.config.ts +3 -0
  57. package/src/component/lib.test.ts +63 -0
  58. package/src/component/lib.ts +466 -0
  59. package/src/component/schema.ts +48 -0
  60. package/src/component/setup.test.ts +6 -0
  61. package/src/react/index.tsx +292 -0
@@ -0,0 +1,156 @@
1
+ export interface TransloaditAuthConfig {
2
+ authKey: string;
3
+ authSecret: string;
4
+ }
5
+
6
+ export interface BuildParamsOptions {
7
+ authKey: string;
8
+ templateId?: string;
9
+ steps?: Record<string, unknown>;
10
+ fields?: Record<string, unknown>;
11
+ notifyUrl?: string;
12
+ numExpectedUploadFiles?: number;
13
+ expires?: string;
14
+ additionalParams?: Record<string, unknown>;
15
+ }
16
+
17
+ export interface BuildParamsResult {
18
+ params: Record<string, unknown>;
19
+ paramsString: string;
20
+ }
21
+
22
+ export function buildTransloaditParams(
23
+ options: BuildParamsOptions,
24
+ ): BuildParamsResult {
25
+ if (!options.templateId && !options.steps) {
26
+ throw new Error("Provide either templateId or steps to create an Assembly");
27
+ }
28
+
29
+ const auth: Record<string, string> = {
30
+ key: options.authKey,
31
+ };
32
+
33
+ auth.expires =
34
+ options.expires ?? new Date(Date.now() + 60 * 60 * 1000).toISOString();
35
+
36
+ const params: Record<string, unknown> = {
37
+ auth,
38
+ };
39
+
40
+ if (options.templateId) params.template_id = options.templateId;
41
+ if (options.steps) params.steps = options.steps;
42
+ if (options.fields) params.fields = options.fields;
43
+ if (options.notifyUrl) params.notify_url = options.notifyUrl;
44
+ if (options.numExpectedUploadFiles !== undefined) {
45
+ params.num_expected_upload_files = options.numExpectedUploadFiles;
46
+ }
47
+
48
+ if (options.additionalParams) {
49
+ for (const [key, value] of Object.entries(options.additionalParams)) {
50
+ if (value !== undefined) {
51
+ params[key] = value;
52
+ }
53
+ }
54
+ }
55
+
56
+ return {
57
+ params,
58
+ paramsString: JSON.stringify(params),
59
+ };
60
+ }
61
+
62
+ async function hmacHex(
63
+ algorithm: "SHA-384" | "SHA-1",
64
+ key: string,
65
+ data: string,
66
+ ): Promise<string> {
67
+ if (globalThis.crypto?.subtle) {
68
+ const encoder = new TextEncoder();
69
+ const cryptoKey = await globalThis.crypto.subtle.importKey(
70
+ "raw",
71
+ encoder.encode(key),
72
+ { name: "HMAC", hash: { name: algorithm } },
73
+ false,
74
+ ["sign"],
75
+ );
76
+ const signature = await globalThis.crypto.subtle.sign(
77
+ "HMAC",
78
+ cryptoKey,
79
+ encoder.encode(data),
80
+ );
81
+ const bytes = new Uint8Array(signature);
82
+ return Array.from(bytes)
83
+ .map((byte) => byte.toString(16).padStart(2, "0"))
84
+ .join("");
85
+ }
86
+
87
+ const { createHmac } = await import("node:crypto");
88
+ return createHmac(algorithm.replace("-", "").toLowerCase(), key)
89
+ .update(data)
90
+ .digest("hex");
91
+ }
92
+
93
+ export async function signTransloaditParams(
94
+ paramsString: string,
95
+ authSecret: string,
96
+ ): Promise<string> {
97
+ const signature = await hmacHex("SHA-384", authSecret, paramsString);
98
+ return `sha384:${signature}`;
99
+ }
100
+
101
+ function safeCompare(a: string, b: string): boolean {
102
+ if (a.length !== b.length) return false;
103
+ let mismatch = 0;
104
+ for (let i = 0; i < a.length; i += 1) {
105
+ mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
106
+ }
107
+ return mismatch === 0;
108
+ }
109
+
110
+ export async function verifyWebhookSignature(options: {
111
+ rawBody: string;
112
+ signatureHeader?: string;
113
+ authSecret: string;
114
+ }): Promise<boolean> {
115
+ if (!options.signatureHeader) return false;
116
+
117
+ const signatureHeader = options.signatureHeader.trim();
118
+ if (!signatureHeader) return false;
119
+
120
+ const [prefix, sig] = signatureHeader.includes(":")
121
+ ? (signatureHeader.split(":") as [string, string])
122
+ : ["sha1", signatureHeader];
123
+
124
+ const normalized = prefix.toLowerCase();
125
+ const algorithm = normalized === "sha384" ? "SHA-384" : "SHA-1";
126
+
127
+ if (normalized !== "sha384" && normalized !== "sha1") {
128
+ return false;
129
+ }
130
+
131
+ const expected = await hmacHex(
132
+ algorithm,
133
+ options.authSecret,
134
+ options.rawBody,
135
+ );
136
+ return safeCompare(expected, sig);
137
+ }
138
+
139
+ export type AssemblyResultRecord = {
140
+ stepName: string;
141
+ result: Record<string, unknown>;
142
+ };
143
+
144
+ export function flattenResults(
145
+ results: Record<string, Array<Record<string, unknown>>> | undefined,
146
+ ): AssemblyResultRecord[] {
147
+ if (!results) return [];
148
+ const output: AssemblyResultRecord[] = [];
149
+ for (const [stepName, entries] of Object.entries(results)) {
150
+ if (!Array.isArray(entries)) continue;
151
+ for (const result of entries) {
152
+ output.push({ stepName, result });
153
+ }
154
+ }
155
+ return output;
156
+ }
@@ -0,0 +1,3 @@
1
+ import { defineComponent } from "convex/server";
2
+
3
+ export default defineComponent("transloadit");
@@ -0,0 +1,63 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ import { createHmac } from "node:crypto";
4
+ import { convexTest } from "convex-test";
5
+ import { describe, expect, test } from "vitest";
6
+ import { api } from "./_generated/api.js";
7
+ import schema from "./schema.js";
8
+ import { modules } from "./setup.test.js";
9
+
10
+ process.env.TRANSLOADIT_AUTH_KEY = "test-key";
11
+ process.env.TRANSLOADIT_AUTH_SECRET = "test-secret";
12
+
13
+ describe("Transloadit component lib", () => {
14
+ test("handleWebhook stores assembly and results", async () => {
15
+ const t = convexTest(schema, modules);
16
+
17
+ const payload = {
18
+ assembly_id: "asm_123",
19
+ ok: "ASSEMBLY_COMPLETED",
20
+ message: "Assembly complete",
21
+ results: {
22
+ resized: [
23
+ {
24
+ id: "file_1",
25
+ ssl_url: "https://example.com/file.jpg",
26
+ name: "file.jpg",
27
+ size: 12345,
28
+ mime: "image/jpeg",
29
+ },
30
+ ],
31
+ },
32
+ };
33
+
34
+ const rawBody = JSON.stringify(payload);
35
+ const signature = createHmac("sha1", "test-secret")
36
+ .update(rawBody)
37
+ .digest("hex");
38
+
39
+ const result = await t.action(api.lib.handleWebhook, {
40
+ payload,
41
+ rawBody,
42
+ signature: `sha1:${signature}`,
43
+ verifySignature: true,
44
+ });
45
+
46
+ expect(result.assemblyId).toBe("asm_123");
47
+ expect(result.resultCount).toBe(1);
48
+
49
+ const assembly = await t.query(api.lib.getAssemblyStatus, {
50
+ assemblyId: "asm_123",
51
+ });
52
+
53
+ expect(assembly?.assemblyId).toBe("asm_123");
54
+ expect(assembly?.ok).toBe("ASSEMBLY_COMPLETED");
55
+
56
+ const results = await t.query(api.lib.listResults, {
57
+ assemblyId: "asm_123",
58
+ });
59
+
60
+ expect(results).toHaveLength(1);
61
+ expect(results[0]?.stepName).toBe("resized");
62
+ });
63
+ });
@@ -0,0 +1,466 @@
1
+ import { type Infer, v } from "convex/values";
2
+ import { internal } from "./_generated/api.js";
3
+ import type { Id } from "./_generated/dataModel.js";
4
+ import {
5
+ action,
6
+ internalMutation,
7
+ mutation,
8
+ query,
9
+ } from "./_generated/server.js";
10
+ import {
11
+ buildTransloaditParams,
12
+ flattenResults,
13
+ signTransloaditParams,
14
+ verifyWebhookSignature,
15
+ } from "./apiUtils.js";
16
+
17
+ const TRANSLOADIT_ASSEMBLY_URL = "https://api2.transloadit.com/assemblies";
18
+
19
+ export const vAssembly = v.object({
20
+ _id: v.id("assemblies"),
21
+ _creationTime: v.number(),
22
+ assemblyId: v.string(),
23
+ status: v.optional(v.string()),
24
+ ok: v.optional(v.string()),
25
+ message: v.optional(v.string()),
26
+ templateId: v.optional(v.string()),
27
+ notifyUrl: v.optional(v.string()),
28
+ numExpectedUploadFiles: v.optional(v.number()),
29
+ fields: v.optional(v.any()),
30
+ uploads: v.optional(v.any()),
31
+ results: v.optional(v.any()),
32
+ error: v.optional(v.any()),
33
+ raw: v.optional(v.any()),
34
+ createdAt: v.number(),
35
+ updatedAt: v.number(),
36
+ userId: v.optional(v.string()),
37
+ });
38
+
39
+ export type Assembly = Infer<typeof vAssembly>;
40
+
41
+ export const vAssemblyResult = v.object({
42
+ _id: v.id("results"),
43
+ _creationTime: v.number(),
44
+ assemblyId: v.string(),
45
+ stepName: v.string(),
46
+ resultId: v.optional(v.string()),
47
+ sslUrl: v.optional(v.string()),
48
+ name: v.optional(v.string()),
49
+ size: v.optional(v.number()),
50
+ mime: v.optional(v.string()),
51
+ raw: v.any(),
52
+ createdAt: v.number(),
53
+ });
54
+
55
+ export type AssemblyResult = Infer<typeof vAssemblyResult>;
56
+
57
+ export const vTransloaditConfig = v.object({
58
+ authKey: v.string(),
59
+ authSecret: v.string(),
60
+ });
61
+
62
+ const vAssemblyBaseArgs = {
63
+ templateId: v.optional(v.string()),
64
+ steps: v.optional(v.any()),
65
+ fields: v.optional(v.any()),
66
+ notifyUrl: v.optional(v.string()),
67
+ numExpectedUploadFiles: v.optional(v.number()),
68
+ expires: v.optional(v.string()),
69
+ additionalParams: v.optional(v.any()),
70
+ userId: v.optional(v.string()),
71
+ };
72
+
73
+ export const upsertAssembly = internalMutation({
74
+ args: {
75
+ assemblyId: v.string(),
76
+ status: v.optional(v.string()),
77
+ ok: v.optional(v.string()),
78
+ message: v.optional(v.string()),
79
+ templateId: v.optional(v.string()),
80
+ notifyUrl: v.optional(v.string()),
81
+ numExpectedUploadFiles: v.optional(v.number()),
82
+ fields: v.optional(v.any()),
83
+ uploads: v.optional(v.any()),
84
+ results: v.optional(v.any()),
85
+ error: v.optional(v.any()),
86
+ raw: v.optional(v.any()),
87
+ userId: v.optional(v.string()),
88
+ },
89
+ returns: v.id("assemblies"),
90
+ handler: async (ctx, args) => {
91
+ const existing = await ctx.db
92
+ .query("assemblies")
93
+ .withIndex("by_assemblyId", (q) => q.eq("assemblyId", args.assemblyId))
94
+ .unique();
95
+
96
+ const now = Date.now();
97
+ if (!existing) {
98
+ return await ctx.db.insert("assemblies", {
99
+ assemblyId: args.assemblyId,
100
+ status: args.status,
101
+ ok: args.ok,
102
+ message: args.message,
103
+ templateId: args.templateId,
104
+ notifyUrl: args.notifyUrl,
105
+ numExpectedUploadFiles: args.numExpectedUploadFiles,
106
+ fields: args.fields,
107
+ uploads: args.uploads,
108
+ results: args.results,
109
+ error: args.error,
110
+ raw: args.raw,
111
+ userId: args.userId,
112
+ createdAt: now,
113
+ updatedAt: now,
114
+ });
115
+ }
116
+
117
+ await ctx.db.patch(existing._id, {
118
+ status: args.status ?? existing.status,
119
+ ok: args.ok ?? existing.ok,
120
+ message: args.message ?? existing.message,
121
+ templateId: args.templateId ?? existing.templateId,
122
+ notifyUrl: args.notifyUrl ?? existing.notifyUrl,
123
+ numExpectedUploadFiles:
124
+ args.numExpectedUploadFiles ?? existing.numExpectedUploadFiles,
125
+ fields: args.fields ?? existing.fields,
126
+ uploads: args.uploads ?? existing.uploads,
127
+ results: args.results ?? existing.results,
128
+ error: args.error ?? existing.error,
129
+ raw: args.raw ?? existing.raw,
130
+ userId: args.userId ?? existing.userId,
131
+ updatedAt: now,
132
+ });
133
+
134
+ return existing._id;
135
+ },
136
+ });
137
+
138
+ export const replaceResultsForAssembly = internalMutation({
139
+ args: {
140
+ assemblyId: v.string(),
141
+ results: v.array(
142
+ v.object({
143
+ stepName: v.string(),
144
+ result: v.any(),
145
+ }),
146
+ ),
147
+ },
148
+ returns: v.null(),
149
+ handler: async (ctx, args) => {
150
+ const existingResults = await ctx.db
151
+ .query("results")
152
+ .withIndex("by_assemblyId", (q) => q.eq("assemblyId", args.assemblyId))
153
+ .collect();
154
+
155
+ for (const existing of existingResults) {
156
+ await ctx.db.delete(existing._id);
157
+ }
158
+
159
+ const now = Date.now();
160
+ for (const entry of args.results) {
161
+ const raw = entry.result as Record<string, unknown>;
162
+ await ctx.db.insert("results", {
163
+ assemblyId: args.assemblyId,
164
+ stepName: entry.stepName,
165
+ resultId: typeof raw.id === "string" ? raw.id : undefined,
166
+ sslUrl: typeof raw.ssl_url === "string" ? raw.ssl_url : undefined,
167
+ name: typeof raw.name === "string" ? raw.name : undefined,
168
+ size: typeof raw.size === "number" ? raw.size : undefined,
169
+ mime: typeof raw.mime === "string" ? raw.mime : undefined,
170
+ raw,
171
+ createdAt: now,
172
+ });
173
+ }
174
+
175
+ return null;
176
+ },
177
+ });
178
+
179
+ export const createAssembly = action({
180
+ args: {
181
+ config: vTransloaditConfig,
182
+ ...vAssemblyBaseArgs,
183
+ },
184
+ returns: v.object({
185
+ assemblyId: v.string(),
186
+ data: v.any(),
187
+ }),
188
+ handler: async (ctx, args) => {
189
+ const { paramsString, params } = buildTransloaditParams({
190
+ authKey: args.config.authKey,
191
+ templateId: args.templateId,
192
+ steps: args.steps as Record<string, unknown> | undefined,
193
+ fields: args.fields as Record<string, unknown> | undefined,
194
+ notifyUrl: args.notifyUrl,
195
+ numExpectedUploadFiles: args.numExpectedUploadFiles,
196
+ expires: args.expires,
197
+ additionalParams: args.additionalParams as
198
+ | Record<string, unknown>
199
+ | undefined,
200
+ });
201
+
202
+ const signature = await signTransloaditParams(
203
+ paramsString,
204
+ args.config.authSecret,
205
+ );
206
+
207
+ const formData = new FormData();
208
+ formData.append("params", paramsString);
209
+ formData.append("signature", signature);
210
+
211
+ const response = await fetch(TRANSLOADIT_ASSEMBLY_URL, {
212
+ method: "POST",
213
+ body: formData,
214
+ });
215
+
216
+ const data = (await response.json()) as Record<string, unknown>;
217
+ if (!response.ok) {
218
+ throw new Error(
219
+ `Transloadit error ${response.status}: ${JSON.stringify(data)}`,
220
+ );
221
+ }
222
+
223
+ const assemblyId =
224
+ typeof data.assembly_id === "string"
225
+ ? data.assembly_id
226
+ : typeof data.assemblyId === "string"
227
+ ? data.assemblyId
228
+ : "";
229
+
230
+ if (!assemblyId) {
231
+ throw new Error("Transloadit response missing assembly_id");
232
+ }
233
+
234
+ await ctx.runMutation(internal.lib.upsertAssembly, {
235
+ assemblyId,
236
+ status: typeof data.ok === "string" ? data.ok : undefined,
237
+ ok: typeof data.ok === "string" ? data.ok : undefined,
238
+ message: typeof data.message === "string" ? data.message : undefined,
239
+ templateId: args.templateId,
240
+ notifyUrl: args.notifyUrl,
241
+ numExpectedUploadFiles: args.numExpectedUploadFiles,
242
+ fields: params.fields,
243
+ uploads: data.uploads,
244
+ results: data.results,
245
+ error: data.error,
246
+ raw: data,
247
+ userId: args.userId,
248
+ });
249
+
250
+ return { assemblyId, data };
251
+ },
252
+ });
253
+
254
+ export const generateUploadParams = action({
255
+ args: {
256
+ config: vTransloaditConfig,
257
+ ...vAssemblyBaseArgs,
258
+ },
259
+ returns: v.object({
260
+ params: v.string(),
261
+ signature: v.string(),
262
+ url: v.string(),
263
+ }),
264
+ handler: async (ctx, args) => {
265
+ const { paramsString } = buildTransloaditParams({
266
+ authKey: args.config.authKey,
267
+ templateId: args.templateId,
268
+ steps: args.steps as Record<string, unknown> | undefined,
269
+ fields: args.fields as Record<string, unknown> | undefined,
270
+ notifyUrl: args.notifyUrl,
271
+ numExpectedUploadFiles: args.numExpectedUploadFiles,
272
+ expires: args.expires,
273
+ additionalParams: args.additionalParams as
274
+ | Record<string, unknown>
275
+ | undefined,
276
+ });
277
+
278
+ const signature = await signTransloaditParams(
279
+ paramsString,
280
+ args.config.authSecret,
281
+ );
282
+
283
+ return {
284
+ params: paramsString,
285
+ signature,
286
+ url: TRANSLOADIT_ASSEMBLY_URL,
287
+ };
288
+ },
289
+ });
290
+
291
+ export const handleWebhook = action({
292
+ args: {
293
+ payload: v.any(),
294
+ rawBody: v.optional(v.string()),
295
+ signature: v.optional(v.string()),
296
+ verifySignature: v.optional(v.boolean()),
297
+ config: v.optional(
298
+ v.object({
299
+ authSecret: v.string(),
300
+ }),
301
+ ),
302
+ },
303
+ returns: v.object({
304
+ assemblyId: v.string(),
305
+ resultCount: v.number(),
306
+ }),
307
+ handler: async (ctx, args) => {
308
+ const rawBody = args.rawBody ?? JSON.stringify(args.payload ?? {});
309
+ const shouldVerify = args.verifySignature ?? true;
310
+ const authSecret =
311
+ args.config?.authSecret ??
312
+ process.env.TRANSLOADIT_AUTH_SECRET ??
313
+ process.env.TRANSLOADIT_SECRET;
314
+
315
+ if (shouldVerify) {
316
+ if (!authSecret) {
317
+ throw new Error(
318
+ "Missing TRANSLOADIT_AUTH_SECRET for webhook validation",
319
+ );
320
+ }
321
+ const verified = await verifyWebhookSignature({
322
+ rawBody,
323
+ signatureHeader: args.signature,
324
+ authSecret,
325
+ });
326
+ if (!verified) {
327
+ throw new Error("Invalid Transloadit webhook signature");
328
+ }
329
+ }
330
+
331
+ const payload = args.payload as Record<string, unknown>;
332
+ const assemblyId =
333
+ typeof payload.assembly_id === "string"
334
+ ? payload.assembly_id
335
+ : typeof payload.assemblyId === "string"
336
+ ? payload.assemblyId
337
+ : "";
338
+
339
+ if (!assemblyId) {
340
+ throw new Error("Webhook payload missing assembly_id");
341
+ }
342
+
343
+ const results = flattenResults(
344
+ (payload.results as Record<string, Array<Record<string, unknown>>>) ??
345
+ undefined,
346
+ );
347
+
348
+ await ctx.runMutation(internal.lib.upsertAssembly, {
349
+ assemblyId,
350
+ status: typeof payload.ok === "string" ? payload.ok : undefined,
351
+ ok: typeof payload.ok === "string" ? payload.ok : undefined,
352
+ message:
353
+ typeof payload.message === "string" ? payload.message : undefined,
354
+ uploads: payload.uploads,
355
+ results: payload.results,
356
+ error: payload.error,
357
+ raw: payload,
358
+ });
359
+
360
+ await ctx.runMutation(internal.lib.replaceResultsForAssembly, {
361
+ assemblyId,
362
+ results,
363
+ });
364
+
365
+ return { assemblyId, resultCount: results.length };
366
+ },
367
+ });
368
+
369
+ export const getAssemblyStatus = query({
370
+ args: { assemblyId: v.string() },
371
+ returns: v.union(vAssembly, v.null()),
372
+ handler: async (ctx, args) => {
373
+ return await ctx.db
374
+ .query("assemblies")
375
+ .withIndex("by_assemblyId", (q) => q.eq("assemblyId", args.assemblyId))
376
+ .unique();
377
+ },
378
+ });
379
+
380
+ export const listAssemblies = query({
381
+ args: {
382
+ status: v.optional(v.string()),
383
+ userId: v.optional(v.string()),
384
+ limit: v.optional(v.number()),
385
+ },
386
+ returns: v.array(vAssembly),
387
+ handler: async (ctx, args) => {
388
+ if (args.userId) {
389
+ return ctx.db
390
+ .query("assemblies")
391
+ .withIndex("by_userId", (q) => q.eq("userId", args.userId))
392
+ .order("desc")
393
+ .take(args.limit ?? 50);
394
+ }
395
+ if (args.status) {
396
+ return ctx.db
397
+ .query("assemblies")
398
+ .withIndex("by_status", (q) => q.eq("status", args.status))
399
+ .order("desc")
400
+ .take(args.limit ?? 50);
401
+ }
402
+
403
+ return ctx.db
404
+ .query("assemblies")
405
+ .order("desc")
406
+ .take(args.limit ?? 50);
407
+ },
408
+ });
409
+
410
+ export const listResults = query({
411
+ args: {
412
+ assemblyId: v.string(),
413
+ stepName: v.optional(v.string()),
414
+ limit: v.optional(v.number()),
415
+ },
416
+ returns: v.array(vAssemblyResult),
417
+ handler: async (ctx, args) => {
418
+ if (args.stepName) {
419
+ const stepName = args.stepName;
420
+ return ctx.db
421
+ .query("results")
422
+ .withIndex("by_assemblyId_and_step", (q) =>
423
+ q.eq("assemblyId", args.assemblyId).eq("stepName", stepName),
424
+ )
425
+ .order("desc")
426
+ .take(args.limit ?? 200);
427
+ }
428
+
429
+ return ctx.db
430
+ .query("results")
431
+ .withIndex("by_assemblyId", (q) => q.eq("assemblyId", args.assemblyId))
432
+ .order("desc")
433
+ .take(args.limit ?? 200);
434
+ },
435
+ });
436
+
437
+ export const storeAssemblyMetadata = mutation({
438
+ args: {
439
+ assemblyId: v.string(),
440
+ userId: v.optional(v.string()),
441
+ fields: v.optional(v.any()),
442
+ },
443
+ returns: v.union(vAssembly, v.null()),
444
+ handler: async (ctx, args) => {
445
+ const existing = await ctx.db
446
+ .query("assemblies")
447
+ .withIndex("by_assemblyId", (q) => q.eq("assemblyId", args.assemblyId))
448
+ .unique();
449
+
450
+ if (!existing) {
451
+ return null;
452
+ }
453
+
454
+ await ctx.db.patch(existing._id, {
455
+ userId: args.userId ?? existing.userId,
456
+ fields: args.fields ?? existing.fields,
457
+ updatedAt: Date.now(),
458
+ });
459
+
460
+ return {
461
+ ...existing,
462
+ userId: args.userId ?? existing.userId,
463
+ fields: args.fields ?? existing.fields,
464
+ };
465
+ },
466
+ });