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
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
useUploadingContext,
} from "@/app/(org)/dashboard/caps/UploadingContext";
import { UpgradeModal } from "@/components/UpgradeModal";
import { imageUrlQuery } from "@/components/VideoThumbnail";
import { ThumbnailRequest } from "@/lib/ThumbnailRequest";

export const UploadCapButton = ({
size = "md",
Expand Down Expand Up @@ -495,7 +495,9 @@ async function legacyUploadCap(
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
queryClient.refetchQueries(imageUrlQuery(uploadId));
queryClient.refetchQueries({
queryKey: ThumbnailRequest.queryKey(uploadId),
});
} else {
reject(
new Error(`Screenshot upload failed with status ${xhr.status}`),
Expand Down
47 changes: 25 additions & 22 deletions apps/web/components/VideoThumbnail.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { LogoSpinner } from "@cap/ui";
import { queryOptions, useQuery } from "@tanstack/react-query";
import type { Video } from "@cap/web-domain";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Use VideoId type from domain (not Video.VideoId).

Domain exports VideoId at top-level. Update imports and annotations.

-import type { Video } from "@cap/web-domain";
+import type { VideoId } from "@cap/web-domain";
@@
-  videoId: Video.VideoId;
+  videoId: VideoId;
@@
-export const useThumnailQuery = (videoId: Video.VideoId) => {
+export const useThumbnailQuery = (videoId: VideoId) => {

Also applies to: 14-14, 51-51

🤖 Prompt for AI Agents
In apps/web/components/VideoThumbnail.tsx around lines 2, 14, and 51, the code
currently references Video.VideoId but the domain exports VideoId at the top
level; update the import to pull VideoId from "@cap/web-domain" (alongside or
instead of Video) and replace all usages of Video.VideoId with the top-level
VideoId type in props, annotations and any local variables so the file uses
VideoId directly.

import clsx from "clsx";
import { Effect } from "effect";
import moment from "moment";
import Image from "next/image";
import { memo, useEffect, useRef } from "react";
import { useEffectQuery } from "@/lib/EffectRuntime";
import { ThumbnailRequest } from "@/lib/ThumbnailRequest";

export type ImageLoadingStatus = "loading" | "success" | "error";

interface VideoThumbnailProps {
videoId: string;
videoId: Video.VideoId;
alt: string;
imageClass?: string;
objectFit?: string;
Expand Down Expand Up @@ -45,17 +48,17 @@ function generateRandomGrayScaleColor() {
return `rgb(${grayScaleValue}, ${grayScaleValue}, ${grayScaleValue})`;
}

export const imageUrlQuery = (videoId: string) =>
queryOptions({
queryKey: ["thumbnail", videoId],
queryFn: async () => {
const response = await fetch(`/api/thumbnail?videoId=${videoId}`);
if (response.ok) {
const data = await response.json();
return data.screen;
} else throw new Error("Failed to fetch pre-signed URLs");
},
export const useThumnailQuery = (videoId: Video.VideoId) => {
return useEffectQuery({
queryKey: ThumbnailRequest.queryKey(videoId),
queryFn: Effect.fn(function* () {
return yield* Effect.request(
new ThumbnailRequest.ThumbnailRequest({ videoId }),
yield* ThumbnailRequest.DataLoaderResolver,
);
}),
});
};

export const VideoThumbnail: React.FC<VideoThumbnailProps> = memo(
({
Expand All @@ -68,7 +71,7 @@ export const VideoThumbnail: React.FC<VideoThumbnailProps> = memo(
imageStatus,
setImageStatus,
}) => {
const imageUrl = useQuery(imageUrlQuery(videoId));
const thumbnailUrl = useThumnailQuery(videoId);
const imageRef = useRef<HTMLImageElement>(null);

const randomGradient = `linear-gradient(to right, ${generateRandomGrayScaleColor()}, ${generateRandomGrayScaleColor()})`;
Expand All @@ -87,26 +90,21 @@ export const VideoThumbnail: React.FC<VideoThumbnailProps> = memo(
)}
>
<div className="flex absolute inset-0 z-10 justify-center items-center">
{imageUrl.isError || imageStatus === "error" ? (
{thumbnailUrl.isError || imageStatus === "error" ? (
<div
className="w-full h-full"
style={{ backgroundImage: randomGradient }}
/>
) : (
(imageUrl.isPending || imageStatus === "loading") && (
(thumbnailUrl.isPending || imageStatus === "loading") && (
<LogoSpinner className="w-5 h-auto animate-spin md:w-8" />
)
)}
</div>
{videoDuration && (
<p className="text-white leading-0 px-2 left-3 rounded-full backdrop-blur-sm absolute z-10 bottom-3 bg-black/50 text-[11px]">
{formatDuration(videoDuration)}
</p>
)}
{imageUrl.data && (
{thumbnailUrl.data && (
<Image
ref={imageRef}
src={imageUrl.data}
src={thumbnailUrl.data}
fill={true}
sizes="(max-width: 768px) 100vw, 33vw"
alt={alt}
Expand All @@ -121,6 +119,11 @@ export const VideoThumbnail: React.FC<VideoThumbnailProps> = memo(
onError={() => setImageStatus("error")}
/>
)}
{videoDuration && (
<p className="text-white leading-0 px-2 left-3 rounded-full backdrop-blur-sm absolute z-10 bottom-3 bg-black/50 text-[11px]">
{formatDuration(videoDuration)}
</p>
)}
</div>
);
},
Expand Down
7 changes: 6 additions & 1 deletion apps/web/lib/EffectRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@ import {
makeUseEffectQuery,
} from "./effect-react-query";
import { Rpc } from "./Rpcs";
import { ThumbnailRequest } from "./ThumbnailRequest";

const RuntimeLayer = Layer.mergeAll(Rpc.Default, FetchHttpClient.layer);
export const RuntimeLayer = Layer.mergeAll(
ThumbnailRequest.DataLoaderResolver.Default,
Rpc.Default,
FetchHttpClient.layer,
);

export type RuntimeLayer = typeof RuntimeLayer;

Expand Down
54 changes: 54 additions & 0 deletions apps/web/lib/ThumbnailRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { Video } from "@cap/web-domain";
import { dataLoader } from "@effect/experimental/RequestResolver";
import { Effect, Request, RequestResolver } from "effect";
import type { NonEmptyArray } from "effect/Array";
import { Rpc } from "@/lib/Rpcs";

export namespace ThumbnailRequest {
export const queryKey = (videoId: string) => ["thumbnail", videoId] as const;

export class ThumbnailRequest extends Request.Class<
string,
unknown,
{ videoId: Video.VideoId }
> {}

export class DataLoaderResolver extends Effect.Service<DataLoaderResolver>()(
"ThumbnailRequest/DataLoaderResolver",
{
scoped: Effect.gen(function* () {
const rpc = yield* Rpc;

const requestResolver = RequestResolver.makeBatched(
(requests: NonEmptyArray<ThumbnailRequest>) => {
return rpc.VideosGetThumbnails(requests.map((r) => r.videoId)).pipe(
Effect.flatMap(
// biome-ignore lint/suspicious/useIterableCallbackReturn: effect
Effect.forEach((result, index) =>
Effect.matchEffect(Effect.flatten(result), {
onSuccess: (v) => Request.succeed(requests[index]!, v),
onFailure: (e) => Request.fail(requests[index]!, e),
}),
),
),
Effect.catchAll((error) =>
Effect.forEach(
requests,
(request) => Request.fail(request, error),
{
discard: true,
},
),
),
);
},
);

return yield* dataLoader(requestResolver, {
window: "10 millis",
});
}),
dependencies: [Rpc.Default],
},
) {}
}
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@deepgram/sdk": "^3.3.4",
"@dub/analytics": "^0.0.27",
"@effect/cluster": "^0.50.4",
"@effect/experimental": "^0.56.0",
"@effect/opentelemetry": "^0.58.0",
"@effect/platform": "^0.92.1",
"@effect/platform-node": "^0.98.3",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/utils/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { buildEnv, serverEnv } from "@cap/env";
import { buildEnv } from "@cap/env";
import { type ClassValue, clsx } from "clsx";
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
import type { NextRequest } from "next/server";
Expand Down
29 changes: 28 additions & 1 deletion packages/web-backend/src/Videos/VideosRpcs.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { InternalError, Video } from "@cap/web-domain";
import { Effect } from "effect";
import { Effect, Exit, Schema, Unify } from "effect";

import { provideOptionalAuth } from "../Auth.ts";
import { Videos } from "./index.ts";
Expand Down Expand Up @@ -43,6 +43,33 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer(
S3Error: () => new InternalError({ type: "s3" }),
}),
),

VideosGetThumbnails: (videoIds) =>
Effect.all(
videoIds.map((id) =>
videos.getThumbnailURL(id).pipe(
Effect.catchTags({
DatabaseError: () => new InternalError({ type: "database" }),
S3Error: () => new InternalError({ type: "s3" }),
}),
Effect.matchEffect({
onSuccess: (v) => Effect.succeed(Exit.succeed(v)),
onFailure: (e) =>
Schema.is(InternalError)(e)
? Effect.fail(e)
: Effect.succeed(Exit.fail(e)),
}),
Effect.map((v) => Unify.unify(v)),
),
),
{ concurrency: 10 },
).pipe(
provideOptionalAuth,
Effect.catchTags({
DatabaseError: () => new InternalError({ type: "database" }),
UnknownException: () => new InternalError({ type: "unknown" }),
}),
),
Comment on lines +47 to +72
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Per-item errors fail the entire batch instead of being wrapped in Exit.

The current error handling converts DatabaseError and S3Error to InternalError via catchTags (lines 51-54), then uses matchEffect to check if the error is InternalError and fails the entire batch if true (lines 58-59). This means any per-item database or S3 error (e.g., video not found, thumbnail missing) fails the entire batch instead of being recorded as an individual failure.

For a batched endpoint, the typical pattern is:

  • Wrap per-item successes in Exit.succeed
  • Wrap per-item failures in Exit.fail
  • Return an array of Exit results
  • Only fail the batch for system-level errors (connection failures, etc.)

The frontend resolver in ThumbnailRequest.ts expects an array of Exit results and calls Request.succeed or Request.fail on each request individually (lines 27-31), confirming this expectation.

Apply this diff to wrap per-item errors in Exit.fail instead of failing the batch:

 			VideosGetThumbnails: (videoIds) =>
 				Effect.all(
 					videoIds.map((id) =>
 						videos.getThumbnailURL(id).pipe(
-							Effect.catchTags({
-								DatabaseError: () => new InternalError({ type: "database" }),
-								S3Error: () => new InternalError({ type: "s3" }),
-							}),
 							Effect.matchEffect({
 								onSuccess: (v) => Effect.succeed(Exit.succeed(v)),
-								onFailure: (e) =>
-									Schema.is(InternalError)(e)
-										? Effect.fail(e)
-										: Effect.succeed(Exit.fail(e)),
+								onFailure: (e) => {
+									const mappedError =
+										e._tag === "DatabaseError"
+											? new InternalError({ type: "database" })
+											: e._tag === "S3Error"
+												? new InternalError({ type: "s3" })
+												: e;
+									return Effect.succeed(Exit.fail(mappedError));
+								},
 							}),
-							Effect.map((v) => Unify.unify(v)),
 						),
 					),
 					{ concurrency: 10 },
 				).pipe(
 					provideOptionalAuth,
 					Effect.catchTags({
 						DatabaseError: () => new InternalError({ type: "database" }),
 						UnknownException: () => new InternalError({ type: "unknown" }),
 					}),
 				),

This ensures per-item errors are wrapped in Exit.fail while system-level errors (from provideOptionalAuth or Effect.all) still fail the batch. The Unify.unify call is also removed as it's unnecessary after matchEffect always returns Effect<Exit<A, E>>.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
VideosGetThumbnails: (videoIds) =>
Effect.all(
videoIds.map((id) =>
videos.getThumbnailURL(id).pipe(
Effect.catchTags({
DatabaseError: () => new InternalError({ type: "database" }),
S3Error: () => new InternalError({ type: "s3" }),
}),
Effect.matchEffect({
onSuccess: (v) => Effect.succeed(Exit.succeed(v)),
onFailure: (e) =>
Schema.is(InternalError)(e)
? Effect.fail(e)
: Effect.succeed(Exit.fail(e)),
}),
Effect.map((v) => Unify.unify(v)),
),
),
{ concurrency: 10 },
).pipe(
provideOptionalAuth,
Effect.catchTags({
DatabaseError: () => new InternalError({ type: "database" }),
UnknownException: () => new InternalError({ type: "unknown" }),
}),
),
VideosGetThumbnails: (videoIds) =>
Effect.all(
videoIds.map((id) =>
videos.getThumbnailURL(id).pipe(
Effect.matchEffect({
onSuccess: (v) => Effect.succeed(Exit.succeed(v)),
onFailure: (e) => {
const mappedError =
e._tag === "DatabaseError"
? new InternalError({ type: "database" })
: e._tag === "S3Error"
? new InternalError({ type: "s3" })
: e;
return Effect.succeed(Exit.fail(mappedError));
},
}),
),
),
{ concurrency: 10 },
).pipe(
provideOptionalAuth,
Effect.catchTags({
DatabaseError: () => new InternalError({ type: "database" }),
UnknownException: () => new InternalError({ type: "unknown" }),
}),
),

};
}),
);
32 changes: 32 additions & 0 deletions packages/web-backend/src/Videos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,38 @@ export class Videos extends Effect.Service<Videos>()("Videos", {
Effect.transposeOption,
);
}),

getThumbnailURL: Effect.fn("Videos.getThumbnailURL")(function* (
videoId: Video.VideoId,
) {
const videoOpt = yield* repo
.getById(videoId)
.pipe(Policy.withPublicPolicy(policy.canView(videoId)));

return yield* videoOpt.pipe(
Effect.transposeMapOption(
Effect.fn(function* ([video]) {
const [bucket] = yield* S3Buckets.getBucketAccess(video.bucketId);

const listResponse = yield* bucket.listObjects({
prefix: `${video.ownerId}/${video.id}/`,
});
const contents = listResponse.Contents || [];

const thumbnailKey = contents.find((item) =>
item.Key?.endsWith("screen-capture.jpg"),
)?.Key;

if (!thumbnailKey) return Option.none();

return Option.some(
yield* bucket.getSignedObjectUrl(thumbnailKey),
);
}),
),
Effect.map(Option.flatten),
);
Comment on lines +167 to +193
Copy link
Contributor

@coderabbitai coderabbitai bot Oct 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Don’t call .pipe on the resolved tuple.

After yield*, videoOpt is just the [video] tuple, so videoOpt.pipe(...) throws at runtime and the Effect.transposeMapOption never runs. Keep everything inside the effect pipeline instead.

Apply this diff:

-			getThumbnailURL: Effect.fn("Videos.getThumbnailURL")(function* (
-				videoId: Video.VideoId,
-			) {
-				const videoOpt = yield* repo
-					.getById(videoId)
-					.pipe(Policy.withPublicPolicy(policy.canView(videoId)));
-
-				return yield* videoOpt.pipe(
-					Effect.transposeMapOption(
-						Effect.fn(function* ([video]) {
-							const [bucket] = yield* S3Buckets.getBucketAccess(video.bucketId);
-
-							const listResponse = yield* bucket.listObjects({
-								prefix: `${video.ownerId}/${video.id}/`,
-							});
-							const contents = listResponse.Contents || [];
-
-							const thumbnailKey = contents.find((item) =>
-								item.Key?.endsWith("screen-capture.jpg"),
-							)?.Key;
-
-							if (!thumbnailKey) return Option.none();
-
-							return Option.some(
-								yield* bucket.getSignedObjectUrl(thumbnailKey),
-							);
-						}),
-					),
-					Effect.map(Option.flatten),
-				);
-			}),
+			getThumbnailURL: Effect.fn("Videos.getThumbnailURL")((videoId) =>
+				repo
+					.getById(videoId)
+					.pipe(
+						Policy.withPublicPolicy(policy.canView(videoId)),
+						Effect.map(([video]) => video),
+						Effect.flatMap((video) =>
+							Effect.gen(function* () {
+								const [bucket] = yield* S3Buckets.getBucketAccess(
+									video.bucketId,
+								);
+
+								const listResponse = yield* bucket.listObjects({
+									prefix: `${video.ownerId}/${video.id}/`,
+								});
+
+								const thumbnailKey = listResponse.Contents?.find((item) =>
+									item.Key?.endsWith("screen-capture.jpg"),
+								)?.Key;
+
+								if (!thumbnailKey) return Option.none<string>();
+
+								const url = yield* bucket.getSignedObjectUrl(thumbnailKey);
+								return Option.some(url);
+							}),
+						),
+					),
+			),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const videoOpt = yield* repo
.getById(videoId)
.pipe(Policy.withPublicPolicy(policy.canView(videoId)));
return yield* videoOpt.pipe(
Effect.transposeMapOption(
Effect.fn(function* ([video]) {
const [bucket] = yield* S3Buckets.getBucketAccess(video.bucketId);
const listResponse = yield* bucket.listObjects({
prefix: `${video.ownerId}/${video.id}/`,
});
const contents = listResponse.Contents || [];
const thumbnailKey = contents.find((item) =>
item.Key?.endsWith("screen-capture.jpg"),
)?.Key;
if (!thumbnailKey) return Option.none();
return Option.some(
yield* bucket.getSignedObjectUrl(thumbnailKey),
);
}),
),
Effect.map(Option.flatten),
);
getThumbnailURL: Effect.fn("Videos.getThumbnailURL")((videoId) =>
repo
.getById(videoId)
.pipe(
Policy.withPublicPolicy(policy.canView(videoId)),
Effect.map(([video]) => video),
Effect.flatMap((video) =>
Effect.gen(function* () {
const [bucket] = yield* S3Buckets.getBucketAccess(video.bucketId);
const listResponse = yield* bucket.listObjects({
prefix: `${video.ownerId}/${video.id}/`,
});
const thumbnailKey = listResponse.Contents?.find((item) =>
item.Key?.endsWith("screen-capture.jpg"),
)?.Key;
if (!thumbnailKey) return Option.none<string>();
const url = yield* bucket.getSignedObjectUrl(thumbnailKey);
return Option.some(url);
}),
),
),
),
🤖 Prompt for AI Agents
In packages/web-backend/src/Videos/index.ts around lines 167 to 193, the code
yields the result of repo.getById(...) into videoOpt and then calls .pipe on
that resolved tuple (causing a runtime error). Instead, keep the operations
inside the Effect pipeline: call
repo.getById(videoId).pipe(Policy.withPublicPolicy(...)).pipe(Effect.transposeMapOption(...),
Effect.map(Option.flatten)) and return yield* of that whole pipeline; do not
assign the yielded tuple to videoOpt or call .pipe on a resolved value. Ensure
any yield* remain only inside the inner generator (e.g., when calling
S3Buckets.getBucketAccess and bucket methods).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai videoOpt is an Option<T>

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🌐 Web query:

Effect-TS transposeMapOption how does it work with Option pipe

💡 Result:

Short answer

  • Effect.transposeMapOption lets you run an Effect only when an Option is Some; None immediately becomes Effect.succeed(Option.none()). When Some(a), it runs f(a) and wraps the result in Some. See signature and examples. [1]

How it works (concise)

  • Input: Option.
  • If Option is None: returns an Effect that succeeds with Option.none (no effect f run).
  • If Option is Some(a): runs f(a) :: Effect<B, E, R> and returns Effect that succeeds with Option.some(b) (or fails if f fails).
  • Signature: (f: A => Effect<B, E, R>) => (opt: Option) => Effect<Option, E, R>. [1]

Example

  • Using pipe:
    import { pipe } from "effect"
    import * as Option from "effect/Option"
    import * as Effect from "effect/Effect"

    const noneEff = pipe(Option.none(), Effect.transposeMapOption(() => Effect.succeed(42)))
    // noneEff runs to Option.none

    const someEff = pipe(Option.some(21), Effect.transposeMapOption(a => Effect.succeed(a * 2)))
    // someEff runs to Option.some(42)

Reference

  • Effect.transposeMapOption docs. [1]
  • Related Either.transposeMapOption (same transpose idea for Either/Option). [2]

@Brendonovich, you're absolutely right! I apologize for the incorrect analysis. If videoOpt is Option<T> as you've confirmed, then Policy.withPublicPolicy must be returning Effect<Option<[Video]>, E, R> in this context, making the .pipe() call and use of Effect.transposeMapOption entirely correct.

Thank you for the clarification. I'll disregard my previous suggestion.

    (\  /)
    (o.o)  ← learning moment!
    (> <)

}),
};
}),
dependencies: [
Expand Down
2 changes: 1 addition & 1 deletion packages/web-domain/src/Policy.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// shoutout https://lucas-barake.github.io/building-a-composable-policy-system/

import { HttpApiSchema } from "@effect/platform";
import { type Brand, Context, Data, Effect, type Option, Schema } from "effect";
import { Context, Data, Effect, type Option, Schema } from "effect";
import type { NonEmptyReadonlyArray } from "effect/Array";
import { CurrentUser } from "./Authentication.ts";

Expand Down
17 changes: 17 additions & 0 deletions packages/web-domain/src/Video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,4 +169,21 @@ export class VideoRpcs extends RpcGroup.make(
VerifyVideoPasswordError,
),
}),
Rpc.make("VideosGetThumbnails", {
payload: Schema.Array(VideoId).pipe(
Schema.filter((a) => a.length <= 50 || "Maximum of 50 videos at a time"),
),
success: Schema.Array(
Schema.Exit({
success: Schema.Option(Schema.String),
failure: Schema.Union(
NotFoundError,
PolicyDeniedError,
VerifyVideoPasswordError,
),
defect: Schema.Unknown,
}),
),
error: InternalError,
}),
) {}
Loading