@devultur/convex 1.0.2 → 1.1.0

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 (49) hide show
  1. package/README.md +151 -5
  2. package/dist/bin/init.js +9 -2
  3. package/dist/{chunk-FHWT3GD6.js → chunk-GKLFRSGI.js} +2 -0
  4. package/dist/component/_generated/api.d.ts +12 -0
  5. package/dist/component/_generated/api.d.ts.map +1 -1
  6. package/dist/component/_generated/api.js.map +1 -1
  7. package/dist/component/_generated/component.d.ts +55 -1
  8. package/dist/component/_generated/component.d.ts.map +1 -1
  9. package/dist/component/_generated/server.d.ts +1 -1
  10. package/dist/component/_generated/server.d.ts.map +1 -1
  11. package/dist/component/lock.d.ts +33 -0
  12. package/dist/component/lock.d.ts.map +1 -0
  13. package/dist/component/lock.js +47 -0
  14. package/dist/component/lock.js.map +1 -0
  15. package/dist/component/lockValidators.d.ts +25 -0
  16. package/dist/component/lockValidators.d.ts.map +1 -0
  17. package/dist/component/lockValidators.js +14 -0
  18. package/dist/component/lockValidators.js.map +1 -0
  19. package/dist/component/mediaClient.d.ts +3 -0
  20. package/dist/component/mediaClient.d.ts.map +1 -0
  21. package/dist/component/mediaClient.js +7 -0
  22. package/dist/component/mediaClient.js.map +1 -0
  23. package/dist/component/public.d.ts +78 -0
  24. package/dist/component/public.d.ts.map +1 -1
  25. package/dist/component/public.js +83 -19
  26. package/dist/component/public.js.map +1 -1
  27. package/dist/component/schema.d.ts +12 -1
  28. package/dist/component/schema.d.ts.map +1 -1
  29. package/dist/component/schema.js +13 -1
  30. package/dist/component/schema.js.map +1 -1
  31. package/dist/component/uploadKeys.d.ts +14 -0
  32. package/dist/component/uploadKeys.d.ts.map +1 -0
  33. package/dist/component/uploadKeys.js +23 -0
  34. package/dist/component/uploadKeys.js.map +1 -0
  35. package/dist/component/uploads.d.ts +119 -0
  36. package/dist/component/uploads.d.ts.map +1 -0
  37. package/dist/component/uploads.js +106 -0
  38. package/dist/component/uploads.js.map +1 -0
  39. package/dist/component/workerErrors.d.ts +17 -0
  40. package/dist/component/workerErrors.d.ts.map +1 -0
  41. package/dist/component/workerErrors.js +41 -0
  42. package/dist/component/workerErrors.js.map +1 -0
  43. package/dist/index.d.ts +147 -6
  44. package/dist/index.js +102 -4
  45. package/dist/react.d.ts +18 -5
  46. package/dist/react.js +35 -13
  47. package/dist/validators.d.ts +5 -3
  48. package/dist/validators.js +1 -1
  49. package/package.json +8 -3
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  dv
3
- } from "./chunk-FHWT3GD6.js";
3
+ } from "./chunk-GKLFRSGI.js";
4
4
 
5
5
  // src/index.ts
6
6
  import {
@@ -17,7 +17,7 @@ import {
17
17
 
18
18
  // src/client.ts
19
19
  import { extractId } from "@devultur/core";
20
- import { actionGeneric, mutationGeneric, queryGeneric } from "convex/server";
20
+ import { actionGeneric, mutationGeneric, paginationOptsValidator, queryGeneric } from "convex/server";
21
21
  import { v } from "convex/values";
22
22
  var Devultur = class {
23
23
  component;
@@ -33,10 +33,50 @@ var Devultur = class {
33
33
  async loadProgress(ctx, args) {
34
34
  return ctx.runQuery(this.component.public.loadProgress, args);
35
35
  }
36
+ /** An upload URL from your server, optionally locked (retention and/or hold, written with the bytes). */
37
+ async createUploadUrl(ctx, args) {
38
+ return ctx.runAction(this.component.public.createUploadUrl, args);
39
+ }
40
+ /**
41
+ * Records an upload in the component once its bytes are stored (after the PUT): it then shows in `listMedia`.
42
+ * Returns the record, or `null` when nothing is stored at `key`. Uploads nobody confirms are recorded a day later.
43
+ */
44
+ async confirmUpload(ctx, args) {
45
+ return ctx.runAction(this.component.uploads.confirmUpload, args);
46
+ }
47
+ /**
48
+ * The recorded media (every confirmed upload, and every video sent to `processVideo`), optionally of one type.
49
+ * `mediaFromRecord(record)` turns a row into the reference `<Image>` and `dv.media()` take. Unbounded: it reads every
50
+ * record, so a media library pages with `listMediaPage`.
51
+ */
52
+ async listMedia(ctx, args) {
53
+ return ctx.runQuery(this.component.public.listMedia, args ?? {});
54
+ }
55
+ /** One page of the recorded media, newest first, optionally of one type. Takes Convex's `paginationOpts`. */
56
+ async listMediaPage(ctx, args) {
57
+ return ctx.runQuery(this.component.public.listMediaPage, args);
58
+ }
59
+ /** The lock in force on `key`. */
60
+ async getLock(ctx, key) {
61
+ return ctx.runAction(this.component.lock.getLock, { key });
62
+ }
63
+ /** Sets or extends `key`'s retention (shortening a governance one needs `bypassGovernance` and the Worker's hook). */
64
+ async setLock(ctx, key, options) {
65
+ return ctx.runAction(this.component.lock.setLock, { key, ...options });
66
+ }
67
+ /** Holds `keys` (any number: sent in pages). A bulk hold by prefix uses `@devultur/core`'s client on your server. */
68
+ async setHold(ctx, keys) {
69
+ return ctx.runAction(this.component.lock.setHold, { keys });
70
+ }
71
+ /** Releases `keys`' holds: a retention `period` starts now; without a retention they're free. */
72
+ async releaseHold(ctx, keys) {
73
+ return ctx.runAction(this.component.lock.releaseHold, { keys });
74
+ }
36
75
  static exposeApi(component, config) {
37
76
  const preset = config?.video?.transcodePreset;
38
77
  const locales = config?.video?.captionLocales ?? [];
39
78
  const visibilityArg = v.optional(v.union(v.literal("public"), v.literal("private")));
79
+ const uploadLock = config?.upload?.lock;
40
80
  return {
41
81
  issueViewerToken: actionGeneric({
42
82
  args: { sessionSecret: v.optional(v.string()) },
@@ -47,7 +87,23 @@ var Devultur = class {
47
87
  createUploadUrl: actionGeneric({
48
88
  args: { filename: v.string(), contentType: v.string(), visibility: visibilityArg },
49
89
  handler: async (ctx, args) => {
50
- return ctx.runAction(component.public.createUploadUrl, args);
90
+ return ctx.runAction(component.public.createUploadUrl, {
91
+ ...args,
92
+ ...uploadLock ? { lock: uploadLock } : {}
93
+ });
94
+ }
95
+ }),
96
+ // Records an upload once its bytes are stored (the Worker HEADs the key): a browser can't list anything
97
+ // that isn't in your bucket. `useDevultur().upload()` and `useMediaUpload` call it after the PUT.
98
+ confirmUpload: actionGeneric({
99
+ args: {
100
+ key: v.string(),
101
+ filename: v.optional(v.string()),
102
+ width: v.optional(v.number()),
103
+ height: v.optional(v.number())
104
+ },
105
+ handler: async (ctx, args) => {
106
+ return ctx.runAction(component.uploads.confirmUpload, args);
51
107
  }
52
108
  }),
53
109
  deleteMedia: actionGeneric({
@@ -79,6 +135,17 @@ var Devultur = class {
79
135
  return ctx.runQuery(component.public.getMediaByKey, { key: mediaKey });
80
136
  }
81
137
  }),
138
+ // The media library, a page at a time (for `usePaginatedQuery`). It lists every record, of every user: export
139
+ // it only when every caller may see the whole library, otherwise wrap `devultur.listMediaPage` with your checks.
140
+ listMediaPage: queryGeneric({
141
+ args: {
142
+ type: v.optional(v.union(v.literal("video"), v.literal("image"), v.literal("file"))),
143
+ paginationOpts: paginationOptsValidator
144
+ },
145
+ handler: async (ctx, args) => {
146
+ return ctx.runQuery(component.public.listMediaPage, args);
147
+ }
148
+ }),
82
149
  saveProgress: mutationGeneric({
83
150
  args: { userId: v.string(), videoId: v.string(), position: v.number() },
84
151
  handler: async (ctx, args) => {
@@ -106,6 +173,34 @@ var Devultur = class {
106
173
  };
107
174
  }
108
175
  };
176
+
177
+ // src/errors.ts
178
+ import { ConvexError } from "convex/values";
179
+ function isErrorData(data) {
180
+ if (typeof data !== "object" || data === null) return false;
181
+ const fields = data;
182
+ return typeof fields.code === "string" && typeof fields.status === "number" && typeof fields.message === "string";
183
+ }
184
+ function isDevulturError(error) {
185
+ return error instanceof ConvexError && isErrorData(error.data);
186
+ }
187
+ function devulturErrorOf(error) {
188
+ return isDevulturError(error) ? error.data : null;
189
+ }
190
+
191
+ // src/media-record.ts
192
+ function mediaFromRecord(record) {
193
+ if (!record.visibility) return null;
194
+ return {
195
+ id: record.key,
196
+ kind: record.type,
197
+ visibility: record.visibility,
198
+ filename: record.filename,
199
+ ...record.objectKey ? { key: record.objectKey } : {},
200
+ ...record.width !== void 0 ? { width: record.width } : {},
201
+ ...record.height !== void 0 ? { height: record.height } : {}
202
+ };
203
+ }
109
204
  export {
110
205
  CaptionLocale,
111
206
  Devultur,
@@ -117,5 +212,8 @@ export {
117
212
  TranscodePreset,
118
213
  TranscodeProvider,
119
214
  TranslationProvider,
120
- dv
215
+ devulturErrorOf,
216
+ dv,
217
+ isDevulturError,
218
+ mediaFromRecord
121
219
  };
package/dist/react.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
2
  import { Visibility, DevulturMedia, MediaUrlOptions, ProcessingPhase, FailureReason, MediaStatusValue } from '@devultur/core';
3
- import { BakeOutputFormat, MediaUploadState, MediaUploadError } from '@devultur/react';
4
- export { MediaUploadError, MediaUploadState } from '@devultur/react';
3
+ import { BakeOutputFormat, MediaUploadState, MediaUploadError, UploadProgress } from '@devultur/react';
4
+ export { MediaUploadError, MediaUploadState, UploadProgress } from '@devultur/react';
5
5
  import { FunctionReference } from 'convex/server';
6
6
 
7
7
  interface CropUploadRenderProps {
@@ -59,6 +59,8 @@ type AnyQuery = FunctionReference<"query">;
59
59
  interface DevulturApi {
60
60
  issueViewerToken: AnyAction;
61
61
  createUploadUrl: AnyAction;
62
+ /** Records an upload in the component after its PUT, so it shows in `listMedia`. Optional (uploads still work). */
63
+ confirmUpload?: AnyAction;
62
64
  deleteMedia: AnyAction;
63
65
  /** Deletes the full folder of a video (raw/hls/thumbnails/captions). Required to delete videos. */
64
66
  deleteVideo?: AnyAction;
@@ -73,7 +75,18 @@ interface DevulturApi {
73
75
  interface UploadOptions {
74
76
  /** Visibility override for this resource. Omitted → inherits the router's default by type. */
75
77
  visibility?: Visibility;
78
+ /** Progress of the PUT: bytes sent, total bytes and their fraction (0 to 1). */
79
+ onProgress?: (progress: UploadProgress) => void;
80
+ /** Aborts the PUT → `upload()` rejects with an `AbortError`. */
81
+ signal?: AbortSignal;
76
82
  }
83
+ /**
84
+ * The reference `upload()` resolves to: a `DevulturMedia` whose `key` is always set (the uploaded object's key; a
85
+ * video's raw file, what `processVideo` takes). It stores as-is in a `dv.media()` field.
86
+ */
87
+ type UploadedMedia = DevulturMedia & {
88
+ readonly key: string;
89
+ };
77
90
  interface DevulturContextValue {
78
91
  token: string | null;
79
92
  baseUrl: string | null;
@@ -83,8 +96,8 @@ interface DevulturContextValue {
83
96
  url: string;
84
97
  key: string;
85
98
  }>;
86
- /** Uploads a file and returns the typed reference ready to save. */
87
- upload: (file: File, options?: UploadOptions) => Promise<DevulturMedia>;
99
+ /** Uploads a file (reporting progress), records it in the component, and returns the reference ready to save. */
100
+ upload: (file: File, options?: UploadOptions) => Promise<UploadedMedia>;
88
101
  /** Delivery URL from a reference (non-hook → usable in handlers and `.map`). Token-transparent. */
89
102
  url: (media: DevulturMedia, options?: MediaRenderOptions) => string;
90
103
  /** Downloads a video's subtitle text for a locale (auth-transparent). */
@@ -278,4 +291,4 @@ interface UseMediaUploadReturn {
278
291
  */
279
292
  declare function useMediaUpload(options?: UseMediaUploadOptions): UseMediaUploadReturn;
280
293
 
281
- export { type CaptionTrack, CropUpload, type CropUploadLabels, type CropUploadProps, type CropUploadRenderProps, DevulturProvider, DownloadLink, Image, type UseMediaUploadOptions, type UseMediaUploadReturn, type VideoProps, type VideoState, useDevultur, useDevulturMedia, useDevulturProgress, useMediaUpload, useMediaUrl, useVideoProps, useVideoState };
294
+ export { type CaptionTrack, CropUpload, type CropUploadLabels, type CropUploadProps, type CropUploadRenderProps, DevulturProvider, DownloadLink, Image, type UploadedMedia, type UseMediaUploadOptions, type UseMediaUploadReturn, type VideoProps, type VideoState, useDevultur, useDevulturMedia, useDevulturProgress, useMediaUpload, useMediaUrl, useVideoProps, useVideoState };
package/dist/react.js CHANGED
@@ -13,7 +13,10 @@ import {
13
13
  SESSION_HEADER,
14
14
  visibilityFromKey
15
15
  } from "@devultur/core";
16
- import { UploadHttpError, uploadFileWithProgress } from "@devultur/react";
16
+ import {
17
+ UploadHttpError,
18
+ uploadFileWithProgress
19
+ } from "@devultur/react";
17
20
  import { useAction, useConvex, useMutation, useQuery } from "convex/react";
18
21
 
19
22
  // src/image-recovery.ts
@@ -175,6 +178,7 @@ function DevulturProvider({ api, baseUrl, children }) {
175
178
  const issueViewerToken = useAction(api.issueViewerToken);
176
179
  const createUploadUrlAction = useAction(api.createUploadUrl);
177
180
  const deleteMediaAction = useAction(api.deleteMedia);
181
+ const confirmUploadAction = useAction(api.confirmUpload ?? api.createUploadUrl);
178
182
  const deleteVideoAction = useAction(api.deleteVideo ?? api.deleteMedia);
179
183
  const refreshTimerRef = React2.useRef(null);
180
184
  const reissueInFlightRef = React2.useRef(null);
@@ -230,26 +234,23 @@ function DevulturProvider({ api, baseUrl, children }) {
230
234
  const upload = React2.useCallback(
231
235
  async (file, options) => {
232
236
  const dimensions = await readImageDimensions(file);
233
- const result = await createUploadUrlAction({
237
+ const target = await createUploadUrlAction({
234
238
  filename: file.name,
235
239
  contentType: file.type,
236
240
  visibility: options?.visibility
237
241
  });
238
- const uploadResponse = await fetch(result.url, {
239
- method: "PUT",
240
- body: file,
241
- headers: { "Content-Type": file.type }
242
- });
243
- if (!uploadResponse.ok) throw new Error(`Upload failed: ${uploadResponse.status}`);
242
+ await uploadFileWithProgress(target.url, file, { onBytes: options?.onProgress, signal: options?.signal });
243
+ if (api.confirmUpload) await confirmUploadBestEffort(confirmUploadAction, target.key, file, dimensions);
244
244
  return {
245
- id: result.id,
246
- kind: result.kind,
247
- visibility: result.visibility,
245
+ id: target.id,
246
+ kind: target.kind,
247
+ visibility: target.visibility,
248
248
  filename: file.name,
249
+ key: target.key,
249
250
  ...dimensions
250
251
  };
251
252
  },
252
- [createUploadUrlAction]
253
+ [createUploadUrlAction, confirmUploadAction, api.confirmUpload]
253
254
  );
254
255
  const url = React2.useCallback(
255
256
  (media, options) => {
@@ -381,6 +382,13 @@ function useMediaUrl(key, options) {
381
382
  return buildMediaUrl(baseUrl, key, { ...options, token: isPublic ? void 0 : token ?? void 0 });
382
383
  }, [key, baseUrl, token, options]);
383
384
  }
385
+ async function confirmUploadBestEffort(confirmUpload, key, file, dimensions) {
386
+ try {
387
+ await confirmUpload({ key, filename: file.name, ...dimensions });
388
+ } catch (error) {
389
+ console.warn("Devultur: could not record the upload; it is recorded later", error);
390
+ }
391
+ }
384
392
  async function readImageDimensions(file) {
385
393
  if (typeof createImageBitmap !== "function" || !file.type.startsWith("image/")) return {};
386
394
  try {
@@ -628,6 +636,7 @@ function useMediaUpload(options) {
628
636
  if (!api) throw new Error("useMediaUpload must be used within <DevulturProvider>");
629
637
  const createUploadUrlAction = useAction(api.createUploadUrl);
630
638
  const deleteMediaAction = useAction(api.deleteMedia);
639
+ const confirmUploadAction = useAction(api.confirmUpload ?? api.createUploadUrl);
631
640
  const processVideoAction = useAction(api.processVideo ?? api.createUploadUrl);
632
641
  const [phase, setPhase] = React2.useState("idle");
633
642
  const [percent, setPercent] = React2.useState(0);
@@ -693,11 +702,13 @@ function useMediaUpload(options) {
693
702
  const rejected = err instanceof UploadHttpError && (err.status === 413 || err.status === 415);
694
703
  return fail(rejected ? "rejected" : "network", err instanceof Error ? err.message : "The upload failed");
695
704
  }
705
+ if (api.confirmUpload) await confirmUploadBestEffort(confirmUploadAction, result.key, file, dimensions);
696
706
  const uploaded = {
697
707
  id: result.id,
698
708
  kind: result.kind,
699
709
  visibility: result.visibility,
700
710
  filename: file.name,
711
+ key: result.key,
701
712
  ...dimensions
702
713
  };
703
714
  setMedia(uploaded);
@@ -708,7 +719,18 @@ function useMediaUpload(options) {
708
719
  if ((process ?? true) && uploaded.kind === "video") triggerProcess(result.key);
709
720
  return uploaded;
710
721
  },
711
- [createUploadUrlAction, deleteMediaAction, triggerProcess, visibility, replaces, process, onComplete, onError]
722
+ [
723
+ createUploadUrlAction,
724
+ confirmUploadAction,
725
+ api.confirmUpload,
726
+ deleteMediaAction,
727
+ triggerProcess,
728
+ visibility,
729
+ replaces,
730
+ process,
731
+ onComplete,
732
+ onError
733
+ ]
712
734
  );
713
735
  const cancel = React2.useCallback(() => {
714
736
  abortRef.current?.abort();
@@ -12,20 +12,22 @@ import { DevulturKind } from '@devultur/core';
12
12
  */
13
13
  declare const dv: {
14
14
  media(kind?: DevulturKind): convex_values.VObject<{
15
+ key?: string | undefined;
15
16
  filename?: string | undefined;
16
- width?: number | undefined;
17
17
  height?: number | undefined;
18
+ width?: number | undefined;
19
+ visibility: "public" | "private";
18
20
  id: string;
19
21
  kind: DevulturKind;
20
- visibility: "public" | "private";
21
22
  }, {
22
23
  kind: convex_values.VLiteral<DevulturKind, "required"> | convex_values.VUnion<"video" | "image" | "file", [convex_values.VLiteral<"image", "required">, convex_values.VLiteral<"video", "required">, convex_values.VLiteral<"file", "required">], "required", never>;
23
24
  id: convex_values.VString<string, "required">;
24
25
  visibility: convex_values.VUnion<"public" | "private", [convex_values.VLiteral<"public", "required">, convex_values.VLiteral<"private", "required">], "required", never>;
25
26
  filename: convex_values.VString<string | undefined, "optional">;
27
+ key: convex_values.VString<string | undefined, "optional">;
26
28
  width: convex_values.VFloat64<number | undefined, "optional">;
27
29
  height: convex_values.VFloat64<number | undefined, "optional">;
28
- }, "required", "filename" | "id" | "kind" | "visibility" | "width" | "height">;
30
+ }, "required", "key" | "filename" | "visibility" | "height" | "width" | "id" | "kind">;
29
31
  };
30
32
 
31
33
  export { dv };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  dv
3
- } from "./chunk-FHWT3GD6.js";
3
+ } from "./chunk-GKLFRSGI.js";
4
4
  export {
5
5
  dv
6
6
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devultur/convex",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Convex component for Devultur media: media records, uploads, transcodes, watch progress and React bindings",
5
5
  "keywords": [
6
6
  "convex",
@@ -51,8 +51,8 @@
51
51
  "dist"
52
52
  ],
53
53
  "dependencies": {
54
- "@devultur/core": "1.0.2",
55
- "@devultur/react": "1.0.2"
54
+ "@devultur/core": "1.1.0",
55
+ "@devultur/react": "1.1.0"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "convex": ">=1.25.0",
@@ -64,10 +64,14 @@
64
64
  }
65
65
  },
66
66
  "devDependencies": {
67
+ "@testing-library/react": "^16.3.3",
67
68
  "@types/node": "^22.0.0",
68
69
  "@types/react": "^19.0.0",
70
+ "@types/react-dom": "^19.3.0",
69
71
  "convex": "^1.21.0",
72
+ "jsdom": "^30.1.3",
70
73
  "react": "^19.0.0",
74
+ "react-dom": "^19.2.7",
71
75
  "tsup": "^8.0.0",
72
76
  "typescript": "^5.8.0"
73
77
  },
@@ -76,6 +80,7 @@
76
80
  },
77
81
  "scripts": {
78
82
  "build": "tsup src/index.ts src/validators.ts src/react.tsx src/bin/init.ts --format esm --dts && tsc -p src/component/tsconfig.json",
83
+ "codegen": "node scripts/codegen.ts",
79
84
  "dev": "tsup src/index.ts src/react.tsx --format esm --dts --watch",
80
85
  "typecheck": "tsc --noEmit"
81
86
  }