diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c671ce929c..cafb77f2569 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,6 +132,7 @@ jobs: __tests__/unit/playback-store.test.ts __tests__/unit/seo-demo-videos.test.ts __tests__/unit/signed-baa-card.test.ts + __tests__/unit/space-page-round-trips.test.ts __tests__/unit/speed-controller.test.ts __tests__/unit/summary-editor.test.ts __tests__/unit/sso-login-pages.test.ts diff --git a/apps/web/__tests__/unit/space-page-round-trips.test.ts b/apps/web/__tests__/unit/space-page-round-trips.test.ts new file mode 100644 index 00000000000..c59957b0542 --- /dev/null +++ b/apps/web/__tests__/unit/space-page-round-trips.test.ts @@ -0,0 +1,268 @@ +import { Database, ImageUploads } from "@cap/web-backend"; +import { + CurrentUser, + Folder, + Organisation, + Space, + User, +} from "@cap/web-domain"; +import { drizzle } from "drizzle-orm/mysql-proxy"; +import { Effect, Layer, Option } from "effect"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import SharedCapsPage from "@/app/(org)/dashboard/spaces/[spaceId]/page"; +import { getVideosByFolderId } from "@/lib/folder"; +import { + Database as BackendDatabase, + type DbClient, +} from "../../../../packages/web-backend/src/Database"; +import { OrganisationsPolicy } from "../../../../packages/web-backend/src/Organisations/OrganisationsPolicy"; +import { Spaces } from "../../../../packages/web-backend/src/Spaces"; +import { SpacesPolicy } from "../../../../packages/web-backend/src/Spaces/SpacesPolicy"; + +const mocks = vi.hoisted(() => ({ + query: vi.fn(), + spaceOrOrg: null as unknown, +})); + +vi.mock("server-only", () => ({})); +vi.mock("@cap/database", async () => { + const { drizzle } = await import("drizzle-orm/mysql-proxy"); + const client = drizzle(mocks.query); + return { db: () => client }; +}); +vi.mock("@cap/database/auth/session", () => ({ + getCurrentUser: async () => ({ id: "user-1", activeOrganizationId: "org-1" }), +})); +vi.mock("@cap/env", () => ({ serverEnv: () => ({}) })); +vi.mock("@cap/web-backend", async () => { + const { drizzle } = await import("drizzle-orm/mysql-proxy"); + const { Effect, Layer } = await import("effect"); + const { resolveEffectiveVideoRules } = await import( + "../../../../packages/web-backend/src/Videos/EffectiveVideoRules" + ); + const client = drizzle(mocks.query); + class Database extends Effect.Service()("Database", { + succeed: { + use: (callback: (db: typeof client) => Promise) => + Effect.promise(() => callback(client)), + }, + }) {} + class ImageUploads extends Effect.Service()("ImageUploads", { + succeed: { resolveImageUrl: (value: string) => Effect.succeed(value) }, + }) {} + class Spaces extends Effect.Service()("Spaces", { + succeed: { getSpaceOrOrg: () => Effect.succeed(mocks.spaceOrOrg) }, + }) {} + return { + Database, + ImageUploads, + Spaces, + makeCurrentUserLayer: () => Layer.empty, + resolveEffectiveVideoRules, + }; +}); +vi.mock("@/lib/server", async () => { + const { Database, ImageUploads, Spaces } = await import("@cap/web-backend"); + return { + runPromise: ( + effect: Effect.Effect, + ) => + Effect.runPromise( + Effect.provide( + effect, + Layer.mergeAll( + Database.Default, + ImageUploads.Default, + Spaces.Default, + ), + ), + ), + }; +}); +vi.mock("@/app/(org)/dashboard/spaces/[spaceId]/SharedCaps", () => ({ + SharedCaps: () => null, +})); + +const PAGE_SIZE = 15; +const LIST_COLUMNS = { + space: [ + "owner-1", + "Walkthrough", + "2026-10-23 12:00:00", + null, + false, + 60, + true, + null, + false, + 0, + 0, + "Owner", + "2026-10-23 12:00:00", + false, + ], + folder: [ + "owner-1", + "Walkthrough", + "2026-10-23 12:00:00", + true, + null, + { type: "webMP4" }, + false, + 60, + null, + "org-1", + 0, + 0, + [], + "Owner", + "2026-10-23 12:00:00", + false, + false, + ], +}; + +function listRows(columns: unknown[]) { + return Array.from({ length: PAGE_SIZE }, (_, index) => [ + `video-${index}`, + ...columns, + ]); +} + +// Holds every query until the page has issued all the queries it can without +// an answer, then answers them together. Each release is one database round +// trip on the page's critical path. +async function countRoundTrips( + load: () => Promise, + columns: unknown[], +) { + const pending: Array<() => void> = []; + mocks.query.mockImplementation( + (sql: string) => + new Promise((resolve) => + pending.push(() => + resolve({ + rows: sql.includes(" group by ") ? listRows(columns) : [], + }), + ), + ), + ); + + let settled = false; + const result = load().finally(() => { + settled = true; + }); + let roundTrips = 0; + let idleTicks = 0; + while (!settled) { + const issued = mocks.query.mock.calls.length; + await new Promise((resolve) => setTimeout(resolve, 0)); + if (mocks.query.mock.calls.length !== issued || pending.length === 0) { + idleTicks = 0; + continue; + } + if (++idleTicks < 3) continue; + idleTicks = 0; + roundTrips++; + for (const release of pending.splice(0)) release(); + } + await result; + return { roundTrips, queries: mocks.query.mock.calls.length }; +} + +beforeEach(() => { + mocks.query.mockReset(); +}); + +describe("space and folder page round trips", () => { + it("loads a space page in two round trips", async () => { + mocks.spaceOrOrg = { + variant: "space", + space: { id: "space-1", organizationId: "org-1", iconUrl: null }, + }; + const result = await countRoundTrips( + () => + SharedCapsPage({ + params: Promise.resolve({ spaceId: "space-1" }), + searchParams: Promise.resolve({}), + }), + LIST_COLUMNS.space, + ); + expect(result).toEqual({ roundTrips: 2, queries: 8 }); + }); + + it("loads an organization page in two round trips", async () => { + mocks.spaceOrOrg = { + variant: "organization", + organization: { id: "org-1", name: "Org", ownerId: "owner-1" }, + }; + const result = await countRoundTrips( + () => + SharedCapsPage({ + params: Promise.resolve({ spaceId: "org-1" }), + searchParams: Promise.resolve({}), + }), + LIST_COLUMNS.space, + ); + expect(result).toEqual({ roundTrips: 2, queries: 7 }); + }); + + it("looks up the space and the organization together", async () => { + const client = drizzle(mocks.query) as unknown as DbClient; + const result = await countRoundTrips( + () => + Effect.flatMap(Spaces, (spaces) => + spaces.getSpaceOrOrg(Space.SpaceId.make("space-1")), + ).pipe( + Effect.provide(Spaces.DefaultWithoutDependencies), + Effect.provideService( + BackendDatabase, + BackendDatabase.make({ + use: (callback) => Effect.promise(() => callback(client)), + }), + ), + Effect.provideService( + SpacesPolicy, + SpacesPolicy.make({ + isMember: () => Effect.void, + isOwner: () => Effect.void, + isAdmin: () => Effect.void, + }), + ), + Effect.provideService( + OrganisationsPolicy, + OrganisationsPolicy.make({ + isMember: () => Effect.void, + isOwner: () => Effect.void, + isAdminOrOwner: () => Effect.void, + }), + ), + Effect.provideService(CurrentUser, { + id: User.UserId.make("user-1"), + email: "user@example.com", + activeOrganizationId: Organisation.OrganisationId.make("org-1"), + iconUrlOrKey: Option.none(), + }), + Effect.runPromise, + ), + [], + ); + + expect(result).toEqual({ roundTrips: 1, queries: 2 }); + }); + + it("loads folder videos in two round trips", async () => { + const result = await countRoundTrips( + () => + getVideosByFolderId(Folder.FolderId.make("folder-1"), { + variant: "user", + }).pipe( + Effect.provide(Database.Default), + Effect.provide(ImageUploads.Default), + Effect.runPromise, + ), + LIST_COLUMNS.folder, + ); + expect(result).toEqual({ roundTrips: 2, queries: 4 }); + }); +}); diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx index 9d8b91a17bf..62d23a93af0 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/page.tsx @@ -71,6 +71,15 @@ async function fetchFolders( .where(and(eq(folders.spaceId, spaceId), isNull(folders.parentId))); } +async function fetchOrganizationSettings(orgId: Organisation.OrganisationId) { + const [row] = await db() + .select({ settings: organizations.settings }) + .from(organizations) + .where(eq(organizations.id, orgId)) + .limit(1); + return row?.settings ?? null; +} + const fetchSpaceMembers = Effect.fn(function* ( spaceId: Space.SpaceIdOrOrganisationId, ) { @@ -296,23 +305,6 @@ export default async function SharedCapsPage(props: { if (spaceOrOrg.variant === "space") { const { space } = spaceOrOrg; - // Fetch members in parallel - const [spaceMembersData, organizationMembersData, foldersData] = - await Promise.all([ - fetchSpaceMembers(space.id).pipe(runPromise), - fetchOrganizationMembers(space.organizationId).pipe(runPromise), - fetchFolders(space.id, false), - ]); - const resolvedSpace = await Effect.gen(function* () { - const imageUploads = yield* ImageUploads; - return { - ...space, - iconUrl: space.iconUrl - ? yield* imageUploads.resolveImageUrl(space.iconUrl) - : null, - }; - }).pipe(runPromise); - async function fetchSpaceVideos( spaceId: Space.SpaceIdOrOrganisationId, page: number, @@ -383,22 +375,32 @@ export default async function SharedCapsPage(props: { }; } - // Fetch videos and count in parallel - const { videos: spaceVideoData, totalCount } = await fetchSpaceVideos( - space.id, - page, - limit, - ); + const [ + spaceMembersData, + organizationMembersData, + foldersData, + { videos: spaceVideoData, totalCount }, + organizationSettings, + ] = await Promise.all([ + fetchSpaceMembers(space.id).pipe(runPromise), + fetchOrganizationMembers(space.organizationId).pipe(runPromise), + fetchFolders(space.id, false), + fetchSpaceVideos(space.id, page, limit), + fetchOrganizationSettings(space.organizationId), + ]); + const resolvedSpace = await Effect.gen(function* () { + const imageUploads = yield* ImageUploads; + return { + ...space, + iconUrl: space.iconUrl + ? yield* imageUploads.resolveImageUrl(space.iconUrl) + : null, + }; + }).pipe(runPromise); const sharedSpacesMap = await fetchSharedSpacesForVideos( spaceVideoData.map((video) => Video.VideoId.make(video.id)), { userId: user.id, organizationId: space.organizationId }, ); - const [organizationSettingsRow] = await db() - .select({ settings: organizations.settings }) - .from(organizations) - .where(eq(organizations.id, space.organizationId)) - .limit(1); - const organizationSettings = organizationSettingsRow?.settings ?? null; const processedVideoData = spaceVideoData.map((video) => { const { effectiveDate: _effectiveDate, ...videoWithoutEffectiveDate } = video; @@ -516,26 +518,21 @@ export default async function SharedCapsPage(props: { }; } - // Fetch videos and count in parallel - - const [organizationVideos, organizationMembersData, foldersData] = - await Promise.all([ - fetchOrganizationVideos(organization.id, page, limit), - fetchOrganizationMembers(organization.id).pipe(runPromise), - fetchFolders(organization.id, true), - ]); - - const { videos: orgVideoData, totalCount } = organizationVideos; + const [ + { videos: orgVideoData, totalCount }, + organizationMembersData, + foldersData, + organizationSettings, + ] = await Promise.all([ + fetchOrganizationVideos(organization.id, page, limit), + fetchOrganizationMembers(organization.id).pipe(runPromise), + fetchFolders(organization.id, true), + fetchOrganizationSettings(organization.id), + ]); const sharedSpacesMap = await fetchSharedSpacesForVideos( orgVideoData.map((video) => Video.VideoId.make(video.id)), { userId: user.id, organizationId: organization.id }, ); - const [organizationSettingsRow] = await db() - .select({ settings: organizations.settings }) - .from(organizations) - .where(eq(organizations.id, organization.id)) - .limit(1); - const organizationSettings = organizationSettingsRow?.settings ?? null; const processedVideoData = orgVideoData.map((video) => { const { effectiveDate: _effectiveDate, ...videoWithoutEffectiveDate } = video; diff --git a/apps/web/lib/folder.ts b/apps/web/lib/folder.ts index aab98e4e17b..41526e98fb1 100644 --- a/apps/web/lib/folder.ts +++ b/apps/web/lib/folder.ts @@ -72,50 +72,52 @@ const getSharedSpacesForVideos = Effect.fn(function* ( if (videoIds.length === 0) return {}; const db = yield* Database; - // Fetch space-level sharing - const spaceSharing = yield* db.use((db) => - db - .select({ - videoId: spaceVideos.videoId, - id: spaces.id, - name: spaces.name, - organizationId: spaces.organizationId, - iconUrl: spaces.iconUrl, - settings: spaces.settings, - hasPassword: sql`${spaces.password} IS NOT NULL`.mapWith(Boolean), - }) - .from(spaceVideos) - .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id)) - .innerJoin(organizations, eq(spaces.organizationId, organizations.id)) - .where( - sql`${spaceVideos.videoId} IN (${sql.join( - videoIds.map((id) => sql`${id}`), - sql`, `, - )})`, + const [spaceSharing, orgSharing] = yield* Effect.all( + [ + db.use((db) => + db + .select({ + videoId: spaceVideos.videoId, + id: spaces.id, + name: spaces.name, + organizationId: spaces.organizationId, + iconUrl: spaces.iconUrl, + settings: spaces.settings, + hasPassword: sql`${spaces.password} IS NOT NULL`.mapWith(Boolean), + }) + .from(spaceVideos) + .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id)) + .innerJoin(organizations, eq(spaces.organizationId, organizations.id)) + .where( + sql`${spaceVideos.videoId} IN (${sql.join( + videoIds.map((id) => sql`${id}`), + sql`, `, + )})`, + ), ), - ); - - // Fetch organization-level sharing - const orgSharing = yield* db.use((db) => - db - .select({ - videoId: sharedVideos.videoId, - id: organizations.id, - name: organizations.name, - organizationId: organizations.id, - iconUrl: organizations.iconUrl, - }) - .from(sharedVideos) - .innerJoin( - organizations, - eq(sharedVideos.organizationId, organizations.id), - ) - .where( - sql`${sharedVideos.videoId} IN (${sql.join( - videoIds.map((id) => sql`${id}`), - sql`, `, - )})`, + db.use((db) => + db + .select({ + videoId: sharedVideos.videoId, + id: organizations.id, + name: organizations.name, + organizationId: organizations.id, + iconUrl: organizations.iconUrl, + }) + .from(sharedVideos) + .innerJoin( + organizations, + eq(sharedVideos.organizationId, organizations.id), + ) + .where( + sql`${sharedVideos.videoId} IN (${sql.join( + videoIds.map((id) => sql`${id}`), + sql`, `, + )})`, + ), ), + ], + { concurrency: "unbounded" }, ); // Combine and group by videoId @@ -261,22 +263,25 @@ export const getVideosByFolderId = Effect.fn(function* ( .orderBy(desc(videos.effectiveCreatedAt)), ); - // Fetch shared spaces data for all videos const videoIds = videoData.map((video) => video.id); - const sharedSpacesMap = yield* getSharedSpacesForVideos(videoIds); const orgIds = Array.from(new Set(videoData.map((video) => video.orgId))); - const organizationSettingsRows = - orgIds.length > 0 - ? yield* db.use((db) => - db - .select({ - id: organizations.id, - settings: organizations.settings, - }) - .from(organizations) - .where(inArray(organizations.id, orgIds)), - ) - : []; + const [sharedSpacesMap, organizationSettingsRows] = yield* Effect.all( + [ + getSharedSpacesForVideos(videoIds), + orgIds.length > 0 + ? db.use((db) => + db + .select({ + id: organizations.id, + settings: organizations.settings, + }) + .from(organizations) + .where(inArray(organizations.id, orgIds)), + ) + : Effect.succeed([]), + ], + { concurrency: "unbounded" }, + ); const organizationSettingsById = Object.fromEntries( organizationSettingsRows.map((organization) => [ organization.id, diff --git a/packages/web-backend/src/Spaces/index.ts b/packages/web-backend/src/Spaces/index.ts index 8c46856ec0e..bab57cf345a 100644 --- a/packages/web-backend/src/Spaces/index.ts +++ b/packages/web-backend/src/Spaces/index.ts @@ -18,42 +18,45 @@ export class Spaces extends Effect.Service()("Spaces", { const getSpaceOrOrg = Effect.fn(function* ( spaceOrOrgId: Space.SpaceIdOrOrganisationId, ) { - const [[space], [org]] = yield* Effect.all([ - db.use((db) => - db - .select({ - id: Db.spaces.id, - name: Db.spaces.name, - organizationId: Db.spaces.organizationId, - createdById: Db.spaces.createdById, - iconUrl: Db.spaces.iconUrl, - settings: Db.spaces.settings, - public: Db.spaces.public, - hasPassword: Dz.sql`${Db.spaces.password} IS NOT NULL`.mapWith( - Boolean, - ), - }) - .from(Db.spaces) - .where(Dz.eq(Db.spaces.id, spaceOrOrgId)) - .limit(1), - ), - db.use((db) => - db - .select({ - id: Db.organizations.id, - name: Db.organizations.name, - ownerId: Db.organizations.ownerId, - }) - .from(Db.organizations) - .where( - Dz.eq( - Db.organizations.id, - spaceOrOrgId as Organisation.OrganisationId, - ), - ) - .limit(1), - ), - ]); + const [[space], [org]] = yield* Effect.all( + [ + db.use((db) => + db + .select({ + id: Db.spaces.id, + name: Db.spaces.name, + organizationId: Db.spaces.organizationId, + createdById: Db.spaces.createdById, + iconUrl: Db.spaces.iconUrl, + settings: Db.spaces.settings, + public: Db.spaces.public, + hasPassword: Dz.sql`${Db.spaces.password} IS NOT NULL`.mapWith( + Boolean, + ), + }) + .from(Db.spaces) + .where(Dz.eq(Db.spaces.id, spaceOrOrgId)) + .limit(1), + ), + db.use((db) => + db + .select({ + id: Db.organizations.id, + name: Db.organizations.name, + ownerId: Db.organizations.ownerId, + }) + .from(Db.organizations) + .where( + Dz.eq( + Db.organizations.id, + spaceOrOrgId as Organisation.OrganisationId, + ), + ) + .limit(1), + ), + ], + { concurrency: "unbounded" }, + ); if (space) return yield* Effect.succeed({ variant: "space" as const, space }).pipe( Policy.withPolicy(