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
51 changes: 51 additions & 0 deletions apps/web/__tests__/unit/loom-import-backfill.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { SQL } from "drizzle-orm";
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
Expand Down Expand Up @@ -301,6 +303,55 @@ describe("backfillLoomImport", () => {
expect(mocks.reassign).not.toHaveBeenCalled();
});

it("repairs a pending conversion left on the previous owner's path", async () => {
mocks.selects = [
[
video({
ownerId: "zephyaaaaaaaaaa",
metadata: {
loomDeferredProcessing: {
status: "pending",
rawFileKey: "lukeaaaaaaaaaaa/videoaaaaaaaaaa/raw-upload.webm",
},
},
}),
],
[lukeFolder("folderaaaaaaaaa")],
];
await expect(
backfillLoomImport(org, { videoId: "videoaaaaaaaaaa" }),
).resolves.toMatchObject({ status: "updated", changes: ["original"] });
expect(mocks.updates).toHaveLength(1);
const query = new MySqlDialect().sqlToQuery(
mocks.updates[0]?.metadata as SQL,
);
expect(query.sql).toContain(
"JSON_SET(?, '$.loomDeferredProcessing.rawFileKey', ?)",
);
expect(query.params).toContain(
"zephyaaaaaaaaaa/videoaaaaaaaaaa/raw-upload.webm",
);
});

it("leaves a conversion already on the owner's path alone", async () => {
mocks.selects = [
[
video({
metadata: {
loomDeferredProcessing: {
status: "pending",
rawFileKey: "lukeaaaaaaaaaaa/videoaaaaaaaaaa/raw-upload.webm",
},
},
}),
],
[lukeFolder("folderaaaaaaaaa")],
];
await expect(
backfillLoomImport(org, { videoId: "videoaaaaaaaaaa" }),
).resolves.toMatchObject({ status: "unchanged" });
});

it("reports moves it could not make or finish cleaning up", async () => {
mocks.selects = [[video()], [lukeFolder("folderaaaaaaaaa")]];
mocks.reassign.mockResolvedValue({
Expand Down
65 changes: 63 additions & 2 deletions apps/web/__tests__/unit/loom-operator-tools.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { HttpApi } from "@effect/platform";
import type { SQL } from "drizzle-orm";
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { Effect, type Layer as EffectLayer, Option } from "effect";
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
Expand Down Expand Up @@ -154,6 +156,7 @@ import {
encryptEditTranscriptObject,
} from "@/lib/edit-transcript-storage";
import { reassignLoomImport } from "@/lib/loom-import-reassign";
import { getOwnerOriginalKey } from "@/lib/loom-original-key";

const call = (
handler: (request: Request) => Promise<Response>,
Expand Down Expand Up @@ -293,7 +296,11 @@ describe("reassignLoomImport", () => {
],
]);
expect(mocks.updates).toEqual([
{ ownerId: ids.to, public: false, folderId: null },
expect.objectContaining({
ownerId: ids.to,
public: false,
folderId: null,
}),
]);
expect(mocks.deletes).toBe(2);
expect(mocks.removed).toEqual([mocks.objects.map(({ Key }) => Key)]);
Expand All @@ -310,6 +317,19 @@ describe("reassignLoomImport", () => {
[importedVideo({ source: { type: "webMP4", outputKey: "edited.mp4" } })],
"edited video",
],
[
[
importedVideo({
metadata: {
loomDeferredProcessing: {
status: "started",
rawFileKey: `${ids.from}/${ids.video}/raw-upload.webm`,
},
},
}),
],
"conversion running",
],
[[importedVideo(), []], "new owner not a member"],
])("leaves unsafe moves alone: %#", async (selects, reason) => {
mocks.selects = (selects as unknown[]).map((row) =>
Expand Down Expand Up @@ -341,6 +361,34 @@ describe("reassignLoomImport", () => {
expect(mocks.claims).toHaveLength(2);
});

it("points a pending conversion at the new owner's copy", async () => {
mocks.selects = [
[
importedVideo({
metadata: {
loomDeferredProcessing: {
status: "pending",
rawFileKey: `someoneaaaaaaaa/${ids.video}/raw-upload.webm`,
},
},
}),
],
[{ userId: ids.to }],
];
await reassignLoomImport(input);
const query = new MySqlDialect().sqlToQuery(
mocks.updates[0]?.metadata as SQL,
);
expect(query.sql).toContain("'$.loomDeferredProcessing.rawFileKey', ?)");
expect(query.params).toEqual([`${ids.to}/${ids.video}/raw-upload.webm`]);
});

it("leaves metadata alone for videos without a pending conversion", async () => {
mocks.selects = [[importedVideo()], [{ userId: ids.to }]];
await reassignLoomImport(input);
expect(mocks.updates[0]).not.toHaveProperty("metadata");
});

it("keeps sharing and visibility when asked", async () => {
mocks.selects = [[importedVideo()], [{ userId: ids.to }]];
await expect(
Expand All @@ -350,8 +398,9 @@ describe("reassignLoomImport", () => {
}),
).resolves.toMatchObject({ status: "moved" });
expect(mocks.updates).toEqual([
{ ownerId: ids.to, folderId: "folderaaaaaaaaa" },
expect.objectContaining({ ownerId: ids.to, folderId: "folderaaaaaaaaa" }),
]);
expect(mocks.updates[0]).not.toHaveProperty("public");
expect(mocks.deletes).toBe(0);
});

Expand Down Expand Up @@ -458,3 +507,15 @@ describe("POST /api/internal/loom-imports/reassign", () => {
});
});
});

describe("getOwnerOriginalKey", () => {
it.each([
["a/vid/raw-upload.webm", "b/vid/raw-upload.webm"],
["a/vid/nested/file.webm", "b/vid/nested/file.webm"],
["a/other/raw-upload.webm", null],
["a/vid/", null],
[undefined, null],
])("%s -> %s", (key, expected) => {
expect(getOwnerOriginalKey(key, "b", "vid")).toBe(expected);
});
});
2 changes: 2 additions & 0 deletions apps/web/lib/loom-deferred-processing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { videos, videoUploads } from "@cap/database/schema";
import type { VideoMetadata } from "@cap/database/types";
import type { Video } from "@cap/web-domain";
import { and, eq, sql } from "drizzle-orm";
import { LOOM_REASSIGN_CLAIM_TTL_MS } from "@/lib/loom-original-key";
import { startVideoProcessingWorkflow } from "@/lib/video-processing";

export const DEFERRED_PROCESSING_RECLAIM_MS = 30 * 60 * 1000;
Expand Down Expand Up @@ -61,6 +62,7 @@ export async function startDeferredLoomProcessing(
and(
eq(videos.id, videoId),
sql`JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.loomDeferredProcessing.rawFileKey')) = ${rawFileKey}`,
sql`(JSON_EXTRACT(${videos.metadata}, '$.loomReassignClaim') IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.loomReassignClaim.at')) < ${new Date(now.getTime() - LOOM_REASSIGN_CLAIM_TTL_MS).toISOString()})`,
sql`(JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.loomDeferredProcessing.status')) = 'pending' OR (JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.loomDeferredProcessing.status')) = 'started' AND JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.loomDeferredProcessing.startedAt')) < ${reclaimBefore}))`,
),
);
Expand Down
25 changes: 25 additions & 0 deletions apps/web/lib/loom-import-backfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
normalizeImportEmail,
} from "@/lib/loom-csv-import";
import { reassignLoomImport } from "@/lib/loom-import-reassign";
import { getOwnerOriginalKey } from "@/lib/loom-original-key";
import { provisionOrganizationInvitee } from "@/lib/organization-provisioning";

export type LoomBackfillItem = {
Expand Down Expand Up @@ -58,6 +59,7 @@ export async function backfillLoomImport(
ownerId: videos.ownerId,
folderId: videos.folderId,
effectiveCreatedAt: videos.effectiveCreatedAt,
metadata: videos.metadata,
orgOwnerId: organizations.ownerId,
})
.from(videos)
Expand Down Expand Up @@ -153,6 +155,29 @@ export async function backfillLoomImport(
changes.push("folder");
}

const deferredKey = video.metadata?.loomDeferredProcessing?.rawFileKey;
const repairedKey = getOwnerOriginalKey(deferredKey, ownerId, item.videoId);
if (
ownerId === video.ownerId &&
Comment thread
richiemcilroy marked this conversation as resolved.
video.metadata?.loomDeferredProcessing?.status === "pending" &&
repairedKey &&
repairedKey !== deferredKey
) {
await db()
.update(videos)
.set({
metadata: sql`JSON_SET(${videos.metadata}, '$.loomDeferredProcessing.rawFileKey', ${repairedKey})`,
})
.where(
and(
eq(videos.id, id),
eq(videos.ownerId, ownerId),
sql`JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.loomDeferredProcessing.rawFileKey')) = ${deferredKey}`,
),
);
changes.push("original");
}

if (date && video.effectiveCreatedAt?.getTime() !== date.getTime()) {
await db()
.update(videos)
Expand Down
28 changes: 24 additions & 4 deletions apps/web/lib/loom-import-reassign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import {
decryptEditTranscriptObject,
encryptEditTranscriptObject,
} from "@/lib/edit-transcript-storage";
import {
getOwnerOriginalKey,
LOOM_REASSIGN_CLAIM_TTL_MS,
} from "@/lib/loom-original-key";
import { runPromise } from "@/lib/server";

export type LoomReassignOptions = {
Expand All @@ -35,8 +39,6 @@ export type LoomReassignOutcome =
}
| { videoId: string; status: "skipped" | "failed"; reason: string };

const CLAIM_TTL_MS = 60 * 60 * 1000;

function getAffectedRows(result: unknown) {
const header = Array.isArray(result) ? result[0] : result;
return (header as { affectedRows?: number } | undefined)?.affectedRows ?? 0;
Expand Down Expand Up @@ -104,6 +106,7 @@ export async function reassignLoomImport({
ownerId: videos.ownerId,
orgId: videos.orgId,
source: videos.source,
metadata: videos.metadata,
bucketId: videos.bucket,
storageIntegrationId: videos.storageIntegrationId,
uploadPhase: videoUploads.phase,
Expand Down Expand Up @@ -132,6 +135,8 @@ export async function reassignLoomImport({
return { videoId, status: "skipped", reason: "import not finished" };
if (video.source && "outputKey" in video.source && video.source.outputKey)
return { videoId, status: "skipped", reason: "edited video" };
if (video.metadata?.loomDeferredProcessing?.status === "started")
return { videoId, status: "skipped", reason: "conversion running" };
Comment thread
richiemcilroy marked this conversation as resolved.

const [member] = await db()
.select({ userId: organizationMembers.userId })
Expand Down Expand Up @@ -160,7 +165,7 @@ export async function reassignLoomImport({
and(
eq(videos.id, id),
eq(videos.ownerId, User.UserId.make(fromUserId)),
sql`(JSON_EXTRACT(${videos.metadata}, '$.loomReassignClaim') IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.loomReassignClaim.at')) < ${new Date(claimedAt.getTime() - CLAIM_TTL_MS).toISOString()})`,
sql`(JSON_EXTRACT(${videos.metadata}, '$.loomReassignClaim') IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.loomReassignClaim.at')) < ${new Date(claimedAt.getTime() - LOOM_REASSIGN_CLAIM_TTL_MS).toISOString()})`,
),
);
if (getAffectedRows(claim) !== 1)
Expand All @@ -174,6 +179,7 @@ export async function reassignLoomImport({
toUserId,
token,
options,
originalKey: video.metadata?.loomDeferredProcessing?.rawFileKey,
});
} finally {
await db()
Expand All @@ -198,6 +204,7 @@ async function moveClaimedVideo({
toUserId,
token,
options,
originalKey,
}: {
id: Video.VideoId;
videoId: string;
Expand All @@ -206,6 +213,7 @@ async function moveClaimedVideo({
toUserId: string;
token: string;
options: LoomReassignOptions;
originalKey: unknown;
}): Promise<LoomReassignOutcome> {
const source = await listObjects(videoId, fromUserId, organizationId);
if (source.objects.length === 0)
Expand Down Expand Up @@ -269,10 +277,16 @@ async function moveClaimedVideo({
}
const copiedObjects = written.length;

const movedOriginalKey = getOwnerOriginalKey(originalKey, toUserId, videoId);
const updated = await db().transaction(async (tx) => {
const result = await tx
.update(videos)
.set({
...(movedOriginalKey
? {
metadata: sql`JSON_SET(${videos.metadata}, '$.loomDeferredProcessing.rawFileKey', ${movedOriginalKey})`,
}
: {}),
ownerId: User.UserId.make(toUserId),
Comment thread
richiemcilroy marked this conversation as resolved.
folderId: options.folderId
? Folder.FolderId.make(options.folderId)
Expand All @@ -284,6 +298,8 @@ async function moveClaimedVideo({
eq(videos.id, id),
eq(videos.ownerId, User.UserId.make(fromUserId)),
sql`JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.loomReassignClaim.token')) = ${token}`,
sql`COALESCE(JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.loomDeferredProcessing.status')), '') <> 'started'`,
sql`JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.loomDeferredProcessing.rawFileKey')) <=> ${typeof originalKey === "string" ? originalKey : null}`,
),
);
if (getAffectedRows(result) !== 1) return false;
Expand All @@ -297,7 +313,11 @@ async function moveClaimedVideo({
await runPromise(
source.bucket.deleteObjects(written.map((key) => ({ Key: key }))),
).catch(() => undefined);
return { videoId, status: "failed", reason: "move claim was lost" };
return {
videoId,
status: "failed",
reason: "video changed during the move",
};
}

try {
Expand Down
13 changes: 13 additions & 0 deletions apps/web/lib/loom-original-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export const LOOM_REASSIGN_CLAIM_TTL_MS = 60 * 60 * 1000;

export function getOwnerOriginalKey(
key: unknown,
ownerId: string,
videoId: string,
) {
if (typeof key !== "string") return null;
const [, keyVideoId, ...rest] = key.split("/");
const fileName = rest.join("/");
if (keyVideoId !== videoId || !fileName) return null;
return `${ownerId}/${videoId}/${fileName}`;
}
Loading