evOo-local/packages/db/src/media.postgres.test.ts
Claude 1aab5ab3a8
feat(media): expose asset externalId, filter by ingest state, push device deletions
The desktop Photos sync needs to map awaiting assets back to Photos items,
query only awaiting uploads, and report deletions without a full relisting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:00:02 +00:00

810 lines
33 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { MediaRepos } from "./media.js";
import { createMediaRepos } from "./media.js";
import type { MediaTestDb } from "./media-fixtures.js";
import { BASE_TIME, openMediaTestDb, sourceItem, vectorBytes } from "./media-fixtures.js";
import { InvalidMediaInputError, MediaStateConflictError } from "./media-shared.js";
import { IsolationError } from "./scope.js";
const databaseUrl = process.env.DATABASE_URL;
const describePostgres =
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
describePostgres("media library and organization (PostgreSQL)", () => {
let db: MediaTestDb;
let media: MediaRepos;
beforeAll(() => {
db = openMediaTestDb(databaseUrl!);
media = createMediaRepos(db.prisma);
});
afterAll(async () => {
await db?.cleanup();
});
async function newLibrary(kind: "folder" | "ios_library" = "folder") {
const user = await db.newUser();
const source = await media.createSource(user.spaceId, {
kind,
label: "Library",
ownerUserId: user.userId,
config: kind === "folder" ? { path: "/media/library" } : { deviceId: "device-1" },
});
return { ...user, sourceId: source.id };
}
it("removes device-deleted items and filters by ingest state", async () => {
const { spaceId, sourceId } = await newLibrary("ios_library");
const other = await newLibrary("ios_library");
const items = Array.from({ length: 5 }, (_, index) => sourceItem(index));
await media.upsertSourceItems(spaceId, sourceId, items);
await media.upsertSourceItems(other.spaceId, other.sourceId, items);
const awaiting = await media.searchAssets(spaceId, { ingestStates: ["awaiting_upload"] });
expect(awaiting.items).toHaveLength(5);
expect(awaiting.items.map((asset) => asset.externalId).sort()).toEqual(
items.map((item) => item.externalId).sort(),
);
expect((await media.searchAssets(spaceId, { ingestStates: ["analyzed"] })).items).toEqual([]);
const removed = await media.removeSourceItems(spaceId, sourceId, [
items[0]!.externalId,
items[1]!.externalId,
"unknown",
]);
expect(removed.deleted).toBe(2);
expect((await media.searchAssets(spaceId, {})).items).toHaveLength(3);
expect((await media.searchAssets(other.spaceId, {})).items).toHaveLength(5);
await expect(
media.removeSourceItems(other.spaceId, sourceId, [items[2]!.externalId]),
).rejects.toBeInstanceOf(IsolationError);
});
it("indexes source items once and reports only real changes on the next sync", async () => {
const { spaceId, sourceId } = await newLibrary();
const items = Array.from({ length: 1000 }, (_, index) =>
sourceItem(index, { gpsLat: 48.208_49, gpsLon: 16.372_08 }),
);
const first = await media.upsertSourceItems(spaceId, sourceId, items);
expect(first).toMatchObject({ created: 1000, updated: 0, unchanged: 0 });
expect(new Set(first.changedAssetIds).size).toBe(1000);
const second = await media.upsertSourceItems(spaceId, sourceId, items);
expect(second).toEqual({ created: 0, updated: 0, unchanged: 1000, changedAssetIds: [] });
const edited = items.map((item, index) =>
index < 3 ? { ...item, revision: "r2", isFavorite: true } : item,
);
const third = await media.upsertSourceItems(spaceId, sourceId, edited);
expect(third).toMatchObject({ created: 0, updated: 3, unchanged: 997 });
expect(third.changedAssetIds).toEqual(first.changedAssetIds.slice(0, 3));
const asset = await media.getAssetRecord(spaceId, first.changedAssetIds[0] as string);
expect(asset).toMatchObject({
gpsLat: 48.21,
gpsLon: 16.37,
isFavorite: true,
revision: "r2",
storage: "referenced",
ingestState: "indexed",
});
const sources = await media.listSources(spaceId);
expect(sources).toHaveLength(1);
expect(sources[0]).toMatchObject({ id: sourceId, assetCount: 1000, status: "idle" });
});
it("serializes concurrent syncs of one source without duplicate assets", async () => {
const { spaceId, sourceId } = await newLibrary();
const items = Array.from({ length: 50 }, (_, index) => sourceItem(index));
const results = await Promise.all([
media.upsertSourceItems(spaceId, sourceId, items),
media.upsertSourceItems(spaceId, sourceId, items),
]);
expect(results.map((result) => result.created).sort()).toEqual([0, 50]);
expect(await db.prisma.mediaAsset.count({ where: { sourceId } })).toBe(50);
});
it("marks client-pushed assets as remote and waiting for their upload", async () => {
const { spaceId, sourceId } = await newLibrary("ios_library");
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
const asset = await media.getAssetRecord(spaceId, changedAssetIds[0] as string);
expect(asset).toMatchObject({ storage: "remote", ingestState: "awaiting_upload" });
});
it("keyset pagination covers 1,200 assets exactly once while new assets arrive", async () => {
const { spaceId, sourceId } = await newLibrary();
// Repeated timestamps and undated assets exercise the (capturedAt, id) tie-break.
const items = Array.from({ length: 1200 }, (_, index) =>
sourceItem(index, {
capturedAt: index % 10 === 0 ? null : new Date(BASE_TIME + Math.floor(index / 4) * 1000),
}),
);
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, items);
const original = new Set(changedAssetIds);
const seen: string[] = [];
const order: Array<number | null> = [];
let cursor: string | null = null;
let page = 0;
do {
const result = await media.searchAssets(spaceId, { cursor, limit: 97 });
for (const asset of result.items) {
seen.push(asset.id);
order.push(asset.capturedAt ? Date.parse(asset.capturedAt) : null);
}
cursor = result.nextCursor;
page += 1;
// Newer, older and undated assets land between page reads.
await media.upsertSourceItems(spaceId, sourceId, [
sourceItem(10_000 + page * 3, { capturedAt: new Date(BASE_TIME + 10_000_000 + page) }),
sourceItem(10_001 + page * 3, { capturedAt: new Date(BASE_TIME - 10_000_000 - page) }),
sourceItem(10_002 + page * 3, { capturedAt: null }),
]);
} while (cursor);
expect(new Set(seen).size).toBe(seen.length);
for (const id of original) expect(seen).toContain(id);
// Order: capturedAt descending, undated last.
const firstNull = order.indexOf(null);
expect(order.slice(firstNull).every((value) => value === null)).toBe(true);
const dated = order.slice(0, firstNull) as number[];
for (let index = 1; index < dated.length; index++) {
expect(dated[index - 1]).toBeGreaterThanOrEqual(dated[index] as number);
}
await expect(media.searchAssets(spaceId, { cursor: "not-a-cursor" })).rejects.toBeInstanceOf(
InvalidMediaInputError,
);
});
it("filters search by kind, time, favorites, quality, place, text, person and collection", async () => {
const { spaceId, sourceId } = await newLibrary();
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [
sourceItem(1, {
kind: "video",
gpsLat: 48.2085,
gpsLon: 16.3721,
albumNames: ["Summer Trip"],
isFavorite: true,
}),
sourceItem(2, { kind: "photo", gpsLat: 48.137, gpsLon: 11.575, fileName: "beach_day.jpg" }),
sourceItem(3, { kind: "video", gpsLat: 48.25, gpsLon: 16.4 }),
sourceItem(4, { kind: "video", capturedAt: null, gpsLat: -16.5, gpsLon: 179.99 }),
sourceItem(5, { kind: "photo", gpsLat: -16.5, gpsLon: -179.99, fileName: "100%_done.jpg" }),
]);
const [vienna, munich, nearVienna, dateline, otherSide] = changedAssetIds as [
string,
string,
string,
string,
string,
];
const ids = async (query: Parameters<MediaRepos["searchAssets"]>[1]) =>
(await media.searchAssets(spaceId, query)).items.map((asset) => asset.id).sort();
expect(await ids({ kinds: ["photo"] })).toEqual([munich, otherSide].sort());
expect(await ids({ favoritesOnly: true })).toEqual([vienna]);
expect(
await ids({
from: new Date(BASE_TIME + 2 * 60_000).toISOString(),
to: new Date(BASE_TIME + 3 * 60_000).toISOString(),
}),
).toEqual([munich, nearVienna].sort());
expect(await ids({ near: { lat: 48.2082, lon: 16.3738, radiusKm: 2 } })).toEqual([vienna]);
expect(await ids({ near: { lat: 48.2082, lon: 16.3738, radiusKm: 10 } })).toEqual(
[vienna, nearVienna].sort(),
);
expect(await ids({ near: { lat: -16.5, lon: 180, radiusKm: 5 } })).toEqual(
[dateline, otherSide].sort(),
);
await media.recordAnalysisVersion(spaceId, munich, 1, 0.9);
await media.recordAnalysisVersion(spaceId, vienna, 1, 0.3);
expect(await ids({ minQuality: 0.5 })).toEqual([munich]);
expect(await ids({ text: "summer" })).toEqual([vienna]);
expect(await ids({ text: "BEACH_DAY" })).toEqual([munich]);
expect(await ids({ text: "100%" })).toEqual([otherSide]);
expect(await ids({ text: "%" })).toEqual([otherSide]);
await media.replaceSegments(spaceId, nearVienna, 1, [
{ kind: "shot", startMs: 0, endMs: 1000, labels: ["Sunset"], transcriptExcerpt: "welcome" },
]);
expect(await ids({ text: "sunset" })).toEqual([nearVienna]);
expect(await ids({ text: "WELCOME" })).toEqual([nearVienna]);
const collection = await media.createManualCollection(spaceId, "Harbour walk", [dateline]);
expect(await ids({ text: "harbour" })).toEqual([dateline]);
expect(await ids({ collectionId: collection.id })).toEqual([dateline]);
const [track] = await media.replaceFaceTracks(spaceId, munich, "faces-1", [
{ startMs: 0, endMs: 1000, confidenceMin: 0.8, keyframesPath: "k.json", embedding: null },
]);
const [person] = await media.createPersonSuggestions(spaceId, [
{ centroid: vectorBytes([1, 2]), trackIds: [track?.id as string] },
]);
expect(await ids({ personId: person?.id as string })).toEqual([munich]);
expect(await ids({ sourceId })).toHaveLength(5);
});
it("returns asset details and moves ingest state only from the expected state", async () => {
const { spaceId, sourceId } = await newLibrary();
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
const assetId = changedAssetIds[0] as string;
const claims = await Promise.all([
media.transitionIngestState(spaceId, assetId, { from: ["indexed"], to: "proxying" }),
media.transitionIngestState(spaceId, assetId, { from: ["indexed"], to: "proxying" }),
]);
expect(claims.filter(Boolean)).toHaveLength(1);
expect((await media.assetsNeedingWork(spaceId, "proxying", 10)).map((row) => row.id)).toEqual([
assetId,
]);
const proxied = await media.recordProxy(spaceId, assetId, {
contentHash: "f".repeat(64),
proxyPath: "proxy.mp4",
thumbPath: "thumb.jpg",
durationMs: 12_000,
});
expect(proxied).toMatchObject({ ingestState: "proxied", hasProxy: true, hasThumbnail: true });
expect(
await media.transitionIngestState(spaceId, assetId, {
from: ["proxied"],
to: "failed",
error: "sidecar offline",
}),
).toBe(true);
expect(await media.getAssetRecord(spaceId, assetId)).toMatchObject({
ingestState: "failed",
ingestError: "sidecar offline",
durationMs: 12_000,
});
await media.upsertAnalysis(spaceId, assetId, {
kind: "transcript",
modelId: "whisper",
modelVersion: "1",
data: { language: "en", words: [{ text: "hello" }, { text: "there" }] },
});
await media.replaceSegments(spaceId, assetId, 1, [
{ kind: "scene", startMs: 2000, endMs: 4000, scores: { motion: 0.5 } },
{ kind: "shot", startMs: 0, endMs: 2000, teaserScore: 0.7 },
]);
await media.replaceFaceTracks(spaceId, assetId, "faces-1", [
{ startMs: 0, endMs: 500, confidenceMin: 0.6, keyframesPath: "a.json", embedding: null },
]);
const detail = await media.getAsset(spaceId, assetId);
expect(detail.transcript).toBe("hello there");
expect(detail.segments.map((segment) => segment.startMs)).toEqual([0, 2000]);
expect(detail.segments[1]?.scores).toEqual({ motion: 0.5 });
expect(detail.faceTracks).toHaveLength(1);
expect(await media.recordAnalysisVersion(spaceId, assetId, 2)).toMatchObject({
ingestState: "analyzed",
});
});
it("keeps each analysis model version and returns the latest per kind", async () => {
const { spaceId, sourceId } = await newLibrary();
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
const assetId = changedAssetIds[0] as string;
const v1 = await media.upsertAnalysis(spaceId, assetId, {
kind: "embeddings",
modelId: "siglip",
modelVersion: "1",
vectors: vectorBytes([1, 2, 3]),
});
const again = await media.upsertAnalysis(spaceId, assetId, {
kind: "embeddings",
modelId: "siglip",
modelVersion: "1",
vectors: vectorBytes([4, 5, 6]),
});
expect(again.id).toBe(v1.id);
await new Promise((resolve) => setTimeout(resolve, 5));
await media.upsertAnalysis(spaceId, assetId, {
kind: "embeddings",
modelId: "siglip",
modelVersion: "2",
vectors: vectorBytes([7, 8, 9]),
});
expect(await db.prisma.mediaAnalysis.count({ where: { assetId } })).toBe(2);
const latest = await media.latestAnalysis(spaceId, assetId, "embeddings");
expect(latest?.modelVersion).toBe("2");
expect([...new Float32Array(new Uint8Array(latest?.vectors as Uint8Array).buffer)]).toEqual([
7, 8, 9,
]);
expect(
(await media.latestAnalysesByKind(spaceId, "embeddings")).get(assetId)?.modelVersion,
).toBe("2");
});
it("replaces segments atomically and flags paid-only segments all or nothing", async () => {
const { spaceId, sourceId } = await newLibrary();
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
const assetId = changedAssetIds[0] as string;
await media.replaceSegments(spaceId, assetId, 1, [
{ kind: "shot", startMs: 0, endMs: 1000 },
{ kind: "shot", startMs: 1000, endMs: 2000 },
]);
await expect(
media.replaceSegments(spaceId, assetId, 2, [
{ kind: "shot", startMs: 0, endMs: 1000 },
{ kind: "shot", startMs: 1000, endMs: 1000 },
]),
).rejects.toBeInstanceOf(InvalidMediaInputError);
expect(await media.listSegments(spaceId, [assetId])).toHaveLength(2);
await media.replaceSegments(spaceId, assetId, 2, [{ kind: "scene", startMs: 0, endMs: 3000 }]);
const segments = await media.listSegments(spaceId, [assetId]);
expect(segments).toHaveLength(1);
const segmentId = segments[0]?.id as string;
await expect(
media.setSegmentsPaidOnly(spaceId, [segmentId, "missing"], true),
).rejects.toBeInstanceOf(IsolationError);
expect((await media.listSegments(spaceId, [assetId]))[0]?.paidOnly).toBe(false);
expect(await media.setSegmentsPaidOnly(spaceId, [segmentId], true)).toBe(1);
expect((await media.listSegments(spaceId, [assetId]))[0]?.paidOnly).toBe(true);
});
it("re-clustering keeps user titles and manual collections and drops stale clusters", async () => {
const { spaceId, sourceId } = await newLibrary();
const { changedAssetIds } = await media.upsertSourceItems(
spaceId,
sourceId,
Array.from({ length: 6 }, (_, index) => sourceItem(index + 1)),
);
const [a1, a2, a3, a4, a5, a6] = changedAssetIds as string[];
const cluster = (key: string, title: string, assetIds: string[]) => ({
key,
title,
labelSource: "rule" as const,
startAt: new Date(BASE_TIME),
endAt: new Date(BASE_TIME + 1000),
centroid: { lat: 48.20849, lon: 16.37208 },
coverAssetId: assetIds[0] ?? null,
assetIds,
});
expect(
await media.replaceClusters(spaceId, "session", [
cluster("s1", "Morning", [a1 as string, a2 as string]),
cluster("s2", "Noon", [a3 as string]),
cluster("s3", "Evening", [a4 as string]),
]),
).toEqual({ created: 3, updated: 0, deleted: 0 });
const manual = await media.createManualCollection(spaceId, "Favourites", [
a6 as string,
a1 as string,
]);
const first = (await media.listCollections(spaceId, { kind: "session" })).items;
const morning = first.find((collection) => collection.title === "Morning");
const evening = first.find((collection) => collection.title === "Evening");
await media.renameCollection(spaceId, morning?.id as string, "Market visit");
await media.renameCollection(spaceId, evening?.id as string, "Dinner");
expect(
await media.replaceClusters(spaceId, "session", [
cluster("s1", "Morning again", [a1 as string, a3 as string, a5 as string]),
cluster("s4", "Night", [a2 as string]),
]),
).toEqual({ created: 1, updated: 1, deleted: 1 });
const after = await media.listCollections(spaceId, {});
const byTitle = new Map(after.items.map((collection) => [collection.title, collection]));
expect([...byTitle.keys()].sort()).toEqual(
["Dinner", "Favourites", "Market visit", "Night"].sort(),
);
expect(byTitle.get("Market visit")).toMatchObject({
id: morning?.id,
labelSource: "user",
assetCount: 3,
videoCount: 2,
totalDurationMs: 20_000,
});
const manualAfter = await media.getCollection(spaceId, manual.id);
expect(manualAfter.collection).toMatchObject({ kind: "manual", title: "Favourites" });
expect(manualAfter.assets.map((asset) => asset.id)).toEqual([a1, a6]);
const stored = await db.prisma.mediaCollection.findUniqueOrThrow({
where: { id: morning?.id as string },
});
expect(stored).toMatchObject({ centroidLat: 48.21, centroidLon: 16.37 });
await expect(
media.replaceClusters(spaceId, "session", [cluster("x", "Bad", ["foreign-asset"])]),
).rejects.toBeInstanceOf(IsolationError);
});
it("lists, merges, splits and creates collections", async () => {
const { spaceId, sourceId } = await newLibrary();
const { changedAssetIds } = await media.upsertSourceItems(
spaceId,
sourceId,
Array.from({ length: 5 }, (_, index) => sourceItem(index + 1)),
);
const ids = changedAssetIds as string[];
const clusters = ids.map((assetId, index) => ({
key: `t${index}`,
title: `Theme ${index}`,
labelSource: "model" as const,
startAt: index === 4 ? null : new Date(BASE_TIME + index * 1000),
endAt: null,
centroid: null,
coverAssetId: assetId,
assetIds: [assetId],
}));
await media.replaceClusters(spaceId, "theme", clusters);
const seen: string[] = [];
let cursor: string | null = null;
do {
const page = await media.listCollections(spaceId, { kind: "theme", cursor, limit: 2 });
seen.push(...page.items.map((collection) => collection.title));
cursor = page.nextCursor;
} while (cursor);
expect(seen).toEqual(["Theme 3", "Theme 2", "Theme 1", "Theme 0", "Theme 4"]);
const all = (await media.listCollections(spaceId, { kind: "theme" })).items;
const merged = await media.mergeCollections(
spaceId,
all.slice(0, 3).map((collection) => collection.id),
);
expect(merged).toMatchObject({ id: all[0]?.id, assetCount: 3, labelSource: "user" });
expect((await media.listCollections(spaceId, { kind: "theme" })).items).toHaveLength(3);
const mergedAssets = (await media.getCollection(spaceId, merged.id)).assets.map(
(asset) => asset.id,
);
expect(mergedAssets).toEqual([ids[1], ids[2], ids[3]]);
await expect(
media.splitCollection(spaceId, merged.id, ids[1] as string),
).rejects.toBeInstanceOf(InvalidMediaInputError);
const [kept, split] = await media.splitCollection(spaceId, merged.id, ids[2] as string);
expect(kept).toMatchObject({ id: merged.id, assetCount: 1 });
expect(split).toMatchObject({ assetCount: 2, kind: "theme" });
expect(
await media.collectionAssetIds(spaceId, [split?.id as string, kept?.id as string]),
).toEqual([ids[2], ids[3], ids[1]]);
});
it("suggests, merges and assigns people and records consent only for a member", async () => {
const { spaceId, userId, sourceId } = await newLibrary();
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [
sourceItem(1),
sourceItem(2),
]);
const [assetA, assetB] = changedAssetIds as [string, string];
const tracksA = await media.replaceFaceTracks(spaceId, assetA, "faces-1", [
{
startMs: 0,
endMs: 1000,
confidenceMin: 0.9,
keyframesPath: "a1",
embedding: vectorBytes([1, 0]),
},
{
startMs: 0,
endMs: 1000,
confidenceMin: 0.7,
keyframesPath: "a2",
embedding: vectorBytes([0, 1]),
},
]);
const tracksB = await media.replaceFaceTracks(spaceId, assetB, "faces-1", [
{
startMs: 0,
endMs: 1000,
confidenceMin: 0.8,
keyframesPath: "b1",
embedding: vectorBytes([1, 0]),
},
]);
expect(await media.faceTrackEmbeddings(spaceId, { unassignedOnly: true })).toHaveLength(3);
const people = await media.createPersonSuggestions(spaceId, [
{
centroid: vectorBytes([1, 0]),
trackIds: [tracksA[0]?.id as string, tracksB[0]?.id as string],
},
{ centroid: vectorBytes([0, 1]), trackIds: [tracksA[1]?.id as string] },
]);
expect(people.map((person) => person.displayName)).toEqual(["Person 1", "Person 2"]);
expect(people[0]).toMatchObject({
faceTrackCount: 2,
confirmed: false,
coverThumbAssetId: assetA,
});
expect(await media.faceTrackEmbeddings(spaceId, { unassignedOnly: true })).toHaveLength(0);
expect(await media.personCentroids(spaceId)).toHaveLength(2);
const [first, second] = people as [(typeof people)[0], (typeof people)[0]];
await media.updatePerson(spaceId, first.id, { isSelf: true });
await media.updatePerson(spaceId, second.id, { isSelf: true, displayName: "Me" });
const listed = await media.listPeople(spaceId);
expect(listed.filter((person) => person.isSelf).map((person) => person.id)).toEqual([
second.id,
]);
const mark = await media.createMark(spaceId, {
kind: "tattoo",
label: null,
personId: second.id,
assetId: assetA,
prompt: { tMs: 0, box: { x: 0.1, y: 0.1, w: 0.2, h: 0.2 } },
treatment: "inpaint",
});
const merged = await media.mergePeople(spaceId, [first.id, second.id]);
expect(merged).toMatchObject({ id: first.id, faceTrackCount: 3, confirmed: true });
expect((await media.getMark(spaceId, mark.id)).personId).toBe(first.id);
expect(await media.listPeople(spaceId)).toHaveLength(1);
const reassigned = await media.assignTrack(spaceId, tracksB[0]?.id as string, null);
expect(reassigned.personId).toBeNull();
await expect(
media.assignTrack(spaceId, tracksB[0]?.id as string, "missing-person"),
).rejects.toBeInstanceOf(IsolationError);
const consent = {
personId: first.id,
consentArtifactId: "artifact-1",
consentRecordedAt: new Date(BASE_TIME).toISOString(),
ageVerificationMethod: "document_on_file" as const,
ageVerifiedAt: new Date(BASE_TIME).toISOString(),
};
await expect(
media.recordConsent({ spaceId, recordedByUserId: "" }, consent),
).rejects.toBeInstanceOf(InvalidMediaInputError);
await expect(
media.recordConsent({ spaceId, recordedByUserId: "not-a-member" }, consent),
).rejects.toBeInstanceOf(IsolationError);
await expect(
media.recordConsent(
{ spaceId, recordedByUserId: userId },
{ ...consent, ageVerifiedAt: null },
),
).rejects.toBeInstanceOf(InvalidMediaInputError);
const withConsent = await media.recordConsent({ spaceId, recordedByUserId: userId }, consent);
expect(withConsent).toMatchObject({
consentArtifactId: "artifact-1",
ageVerificationMethod: "document_on_file",
consentRecordedAt: consent.consentRecordedAt,
});
const record = await db.prisma.mediaPerson.findUniqueOrThrow({ where: { id: first.id } });
expect(record.consentRecordedBy).toBe(userId);
});
it("removing a person leaves no biometric bytes behind", async () => {
const { spaceId, sourceId } = await newLibrary();
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
const assetId = changedAssetIds[0] as string;
const tracks = await media.replaceFaceTracks(spaceId, assetId, "faces-1", [
{
startMs: 0,
endMs: 1000,
confidenceMin: 0.9,
keyframesPath: "k1",
embedding: vectorBytes([1, 2]),
},
{
startMs: 0,
endMs: 1000,
confidenceMin: 0.9,
keyframesPath: "k2",
embedding: vectorBytes([3, 4]),
},
{
startMs: 0,
endMs: 1000,
confidenceMin: 0.9,
keyframesPath: "k3",
embedding: vectorBytes([5, 6]),
},
]);
const [person, bystander] = await media.createPersonSuggestions(spaceId, [
{
centroid: vectorBytes([2, 3]),
trackIds: [tracks[0]?.id as string, tracks[1]?.id as string],
},
{ centroid: vectorBytes([5, 6]), trackIds: [tracks[2]?.id as string] },
]);
const personId = person?.id as string;
const mark = await media.createMark(spaceId, {
kind: "birthmark",
label: null,
personId,
assetId,
prompt: { tMs: 0 },
treatment: "blur",
});
await media.recordMarkResult(spaceId, mark.id, {
qualityScore: 0.4,
suggestedFallback: "cover",
maskDir: "masks/m1",
});
const assetMark = await media.createMark(spaceId, {
kind: "license_plate",
label: null,
personId: null,
assetId,
prompt: { tMs: 0 },
treatment: "blur",
});
const removed = await media.removePersonCompletely(spaceId, personId);
expect(removed).toEqual({
deletedFaceTracks: 2,
deletedMarks: 1,
keyframesPaths: expect.arrayContaining(["k1", "k2"]),
maskDirs: ["masks/m1"],
});
const people = await db.prisma.$queryRaw<Array<{ id: string; centroid: Uint8Array | null }>>`
SELECT id, centroid FROM media_people WHERE "spaceId" = ${spaceId}`;
expect(people).toEqual([{ id: bystander?.id, centroid: expect.anything() }]);
const personRows = await db.prisma.$queryRaw<Array<{ count: number }>>`
SELECT count(*)::int AS count FROM media_people WHERE id = ${personId}`;
expect(personRows[0]?.count).toBe(0);
const linked = await db.prisma.$queryRaw<Array<{ count: number }>>`
SELECT count(*)::int AS count FROM media_face_tracks
WHERE "personId" = ${personId} OR id IN (${tracks[0]?.id}, ${tracks[1]?.id})`;
expect(linked[0]?.count).toBe(0);
const embeddings = await db.prisma.$queryRaw<Array<{ id: string }>>`
SELECT id FROM media_face_tracks WHERE "spaceId" = ${spaceId} AND embedding IS NOT NULL`;
expect(embeddings.map((row) => row.id)).toEqual([tracks[2]?.id]);
const marks = await db.prisma.$queryRaw<Array<{ id: string }>>`
SELECT id FROM media_marks WHERE "spaceId" = ${spaceId}`;
expect(marks.map((row) => row.id)).toEqual([assetMark.id]);
await expect(media.removePersonCompletely(spaceId, personId)).rejects.toBeInstanceOf(
IsolationError,
);
});
it("manages marks per asset and per person", async () => {
const { spaceId, sourceId } = await newLibrary();
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [
sourceItem(1),
sourceItem(2),
]);
const [assetA, assetB] = changedAssetIds as [string, string];
const [trackB] = await media.replaceFaceTracks(spaceId, assetB, "faces-1", [
{ startMs: 0, endMs: 1000, confidenceMin: 0.9, keyframesPath: "k", embedding: null },
]);
const [person] = await media.createPersonSuggestions(spaceId, [
{ centroid: vectorBytes([1]), trackIds: [trackB?.id as string] },
]);
const personMark = await media.createMark(spaceId, {
kind: "tattoo",
label: "left forearm",
personId: person?.id as string,
assetId: assetA,
prompt: { tMs: 500, point: { x: 0.2, y: 0.3 } },
treatment: "inpaint",
});
const plate = await media.createMark(spaceId, {
kind: "license_plate",
label: null,
personId: null,
assetId: assetB,
prompt: { tMs: 0, box: { x: 0, y: 0, w: 0.1, h: 0.1 } },
treatment: "blur",
});
expect((await media.listMarks(spaceId, { personId: person?.id })).map((m) => m.id)).toEqual([
personMark.id,
]);
expect((await media.listMarks(spaceId, { assetId: assetB })).map((m) => m.id)).toEqual([
plate.id,
]);
// Person marks follow the person into every asset they appear in.
expect((await media.marksForAsset(spaceId, assetB)).map((m) => m.id).sort()).toEqual(
[personMark.id, plate.id].sort(),
);
const updated = await media.updateMark(spaceId, plate.id, { treatment: "cover", label: "car" });
expect(updated).toMatchObject({ treatment: "cover", label: "car" });
const result = await media.recordMarkResult(spaceId, personMark.id, {
qualityScore: 0.2,
suggestedFallback: "cover",
maskDir: "masks/tattoo",
});
expect(result).toMatchObject({
qualityScore: 0.2,
suggestedFallback: "cover",
maskDir: "masks/tattoo",
prompt: { tMs: 500, point: { x: 0.2, y: 0.3 } },
});
expect(await media.removeMark(spaceId, personMark.id)).toEqual({ maskDir: "masks/tattoo" });
expect(await media.listMarks(spaceId)).toHaveLength(1);
});
it("uploads chunks idempotently, resumes, and completes only when every chunk arrived", async () => {
const { spaceId, userId, sourceId } = await newLibrary("ios_library");
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
const assetId = changedAssetIds[0] as string;
const begin = {
assetId,
userId,
sizeBytes: 10,
sha256: "c".repeat(64),
fileName: "clip.mov",
chunkBytes: 4,
};
const upload = await media.beginUpload(spaceId, begin);
expect(upload).toMatchObject({
sizeBytes: 10,
chunkBytes: 4,
receivedChunks: [],
status: "open",
});
expect((await media.beginUpload(spaceId, begin)).id).toBe(upload.id);
await Promise.all(
Array.from({ length: 6 }, () => media.markChunkReceived(spaceId, upload.id, 1)),
);
await media.markChunkReceived(spaceId, upload.id, 0);
expect((await media.uploadStatus(spaceId, upload.id)).receivedChunks).toEqual([0, 1]);
await expect(media.markChunkReceived(spaceId, upload.id, 3)).rejects.toBeInstanceOf(
InvalidMediaInputError,
);
await expect(
media.completeUpload(spaceId, upload.id, { storagePath: "uploads/clip.mov" }),
).rejects.toBeInstanceOf(MediaStateConflictError);
await media.markChunkReceived(spaceId, upload.id, 2);
const done = await media.completeUpload(spaceId, upload.id, {
storagePath: "uploads/clip.mov",
});
expect(done).toMatchObject({ status: "complete", receivedChunks: [0, 1, 2] });
expect(await media.getAssetRecord(spaceId, assetId)).toMatchObject({
storage: "uploaded",
storagePath: "uploads/clip.mov",
contentHash: "c".repeat(64),
ingestState: "indexed",
});
await expect(media.markChunkReceived(spaceId, upload.id, 0)).rejects.toBeInstanceOf(
MediaStateConflictError,
);
expect((await media.beginUpload(spaceId, begin)).id).not.toBe(upload.id);
});
it("requests uploads only for remote assets", async () => {
const remote = await newLibrary("ios_library");
const { changedAssetIds } = await media.upsertSourceItems(remote.spaceId, remote.sourceId, [
sourceItem(1),
]);
const remoteId = changedAssetIds[0] as string;
await media.transitionIngestState(remote.spaceId, remoteId, {
from: ["awaiting_upload"],
to: "failed",
});
expect(await media.requestUpload(remote.spaceId, [remoteId])).toEqual([remoteId]);
const folder = await newLibrary();
const local = await media.upsertSourceItems(folder.spaceId, folder.sourceId, [sourceItem(1)]);
expect(await media.requestUpload(folder.spaceId, local.changedAssetIds)).toEqual([]);
});
it("updates and removes a source with everything indexed from it", async () => {
const { spaceId, sourceId } = await newLibrary();
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
const synced = await media.updateSourceSync(spaceId, sourceId, {
status: "syncing",
lastSyncCursor: "page-2",
lastSyncedAt: new Date(BASE_TIME),
error: null,
});
expect(synced).toMatchObject({
status: "syncing",
lastSyncCursor: "page-2",
config: { path: "/media/library" },
assetCount: 1,
});
await media.replaceSegments(spaceId, changedAssetIds[0] as string, 1, [
{ kind: "shot", startMs: 0, endMs: 1 },
]);
await media.removeSource(spaceId, sourceId);
expect(await media.listSources(spaceId)).toEqual([]);
expect(await db.prisma.mediaAsset.count({ where: { sourceId } })).toBe(0);
expect(await db.prisma.mediaSegment.count({ where: { spaceId } })).toBe(0);
});
});