Files
lemonspace_app/convex/storage.ts

322 lines
9.3 KiB
TypeScript

import { mutation, type MutationCtx, type QueryCtx } from "./_generated/server";
import { v } from "convex/values";
import { requireAuth } from "./helpers";
import type { Id } from "./_generated/dataModel";
const STORAGE_URL_BATCH_SIZE = 12;
const PERFORMANCE_LOG_THRESHOLD_MS = 250;
function logSlowQuery(label: string, startedAt: number, details: Record<string, unknown>) {
const durationMs = Date.now() - startedAt;
if (durationMs >= PERFORMANCE_LOG_THRESHOLD_MS) {
console.warn(`[storage] ${label} slow`, {
durationMs,
...details,
});
}
}
type StorageUrlMap = Record<string, string | undefined>;
type StorageUrlResult =
| {
storageId: Id<"_storage">;
url: string | undefined;
error: null;
}
| {
storageId: Id<"_storage">;
url: null;
error: string;
};
async function assertCanvasOwner(
ctx: QueryCtx | MutationCtx,
canvasId: Id<"canvases">,
userId: string,
): Promise<void> {
const canvas = await ctx.db.get(canvasId);
if (!canvas || canvas.ownerId !== userId) {
throw new Error("Canvas not found");
}
}
async function resolveStorageUrls(
ctx: QueryCtx | MutationCtx,
storageIds: Array<Id<"_storage">>,
options?: { logLabel?: string },
): Promise<StorageUrlMap> {
const logLabel = options?.logLabel ?? "batchGetUrlsForCanvas";
const resolved: StorageUrlMap = {};
const operationStartedAt = Date.now();
let failedCount = 0;
let totalResolved = 0;
for (let i = 0; i < storageIds.length; i += STORAGE_URL_BATCH_SIZE) {
const batch = storageIds.slice(i, i + STORAGE_URL_BATCH_SIZE);
const batchStartedAt = Date.now();
let batchFailedCount = 0;
const entries = await Promise.all(
batch.map(async (id): Promise<StorageUrlResult> => {
try {
const url = await ctx.storage.getUrl(id);
return { storageId: id, url: url ?? undefined, error: null };
} catch (error) {
return {
storageId: id,
url: null,
error: String(error),
};
}
}),
);
for (const entry of entries) {
if (entry.error) {
failedCount += 1;
batchFailedCount += 1;
console.warn(`[storage.${logLabel}] getUrl failed`, {
storageId: entry.storageId,
error: entry.error,
});
continue;
}
const { storageId, url } = entry;
resolved[storageId] = url ?? undefined;
if (url) {
totalResolved += 1;
}
}
logSlowQuery(`${logLabel}::resolveStorageBatch`, batchStartedAt, {
batchSize: batch.length,
successCount: entries.length - batchFailedCount,
failedCount: batchFailedCount,
cursor: `${i + 1}..${Math.min(i + STORAGE_URL_BATCH_SIZE, storageIds.length)} / ${storageIds.length}`,
});
}
logSlowQuery(logLabel, operationStartedAt, {
requestStorageCount: storageIds.length,
resolvedCount: totalResolved,
failedCount,
});
return resolved;
}
export const generateUploadUrl = mutation({
args: {},
handler: async (ctx) => {
await requireAuth(ctx);
return await ctx.storage.generateUploadUrl();
},
});
/**
* Signierte URLs für alle Storage-Assets eines Canvas (gebündelt).
* `nodes.list` liefert keine URLs mehr, damit Node-Liste schnell bleibt.
*/
export const batchGetUrlsForCanvas = mutation({
args: {
canvasId: v.id("canvases"),
storageIds: v.array(v.id("_storage")),
},
handler: async (ctx, { canvasId, storageIds }) => {
const startedAt = Date.now();
const user = await requireAuth(ctx);
await assertCanvasOwner(ctx, canvasId, user.userId);
const uniqueSortedStorageIds = [...new Set(storageIds)].sort();
if (uniqueSortedStorageIds.length === 0) {
return {};
}
const nodes = await listNodesForCanvas(ctx, canvasId);
const allowedStorageIds = new Set(collectStorageIds(nodes));
const verifiedStorageIds = uniqueSortedStorageIds.filter((storageId) =>
allowedStorageIds.has(storageId),
);
const rejectedStorageIds = uniqueSortedStorageIds.length - verifiedStorageIds.length;
if (rejectedStorageIds > 0) {
console.warn("[storage.batchGetUrlsForCanvas] rejected unowned storage ids", {
canvasId,
requestedCount: uniqueSortedStorageIds.length,
rejectedStorageIds,
});
}
const result = await resolveStorageUrls(ctx, verifiedStorageIds, {
logLabel: "batchGetUrlsForCanvas",
});
logSlowQuery("batchGetUrlsForCanvas::total", startedAt, {
canvasId,
storageIdCount: verifiedStorageIds.length,
rejectedStorageIds,
resolvedCount: Object.keys(result).length,
});
return result;
},
});
export const batchGetUrlsForUserMedia = mutation({
args: {
storageIds: v.array(v.id("_storage")),
},
handler: async (ctx, { storageIds }) => {
const startedAt = Date.now();
const user = await requireAuth(ctx);
const uniqueSortedStorageIds = [...new Set(storageIds)].sort();
if (uniqueSortedStorageIds.length === 0) {
return {};
}
const ownedStorageIds = await collectOwnedImageStorageIdsForUser(ctx, user.userId);
const verifiedStorageIds = uniqueSortedStorageIds.filter((storageId) =>
ownedStorageIds.has(storageId),
);
const rejectedStorageIds = uniqueSortedStorageIds.length - verifiedStorageIds.length;
if (rejectedStorageIds > 0) {
console.warn("[storage.batchGetUrlsForUserMedia] rejected unowned storage ids", {
userId: user.userId,
requestedCount: uniqueSortedStorageIds.length,
rejectedStorageIds,
});
}
const result = await resolveStorageUrls(ctx, verifiedStorageIds, {
logLabel: "batchGetUrlsForUserMedia",
});
logSlowQuery("batchGetUrlsForUserMedia::total", startedAt, {
userId: user.userId,
storageIdCount: verifiedStorageIds.length,
rejectedStorageIds,
resolvedCount: Object.keys(result).length,
});
return result;
},
});
export const registerUploadedImageMedia = mutation({
args: {
canvasId: v.id("canvases"),
nodeId: v.optional(v.id("nodes")),
storageId: v.id("_storage"),
filename: v.optional(v.string()),
mimeType: v.optional(v.string()),
width: v.optional(v.number()),
height: v.optional(v.number()),
},
handler: async (ctx, args) => {
const user = await requireAuth(ctx);
await assertCanvasOwner(ctx, args.canvasId, user.userId);
if (args.nodeId) {
const node = await ctx.db.get(args.nodeId);
if (!node) {
console.warn("[storage.registerUploadedImageMedia] node not found", {
userId: user.userId,
canvasId: args.canvasId,
nodeId: args.nodeId,
storageId: args.storageId,
});
} else if (node.canvasId !== args.canvasId) {
console.warn("[storage.registerUploadedImageMedia] node/canvas mismatch", {
userId: user.userId,
canvasId: args.canvasId,
nodeId: args.nodeId,
nodeCanvasId: node.canvasId,
storageId: args.storageId,
});
}
}
console.info("[storage.registerUploadedImageMedia] acknowledged", {
userId: user.userId,
canvasId: args.canvasId,
nodeId: args.nodeId,
storageId: args.storageId,
filename: args.filename,
mimeType: args.mimeType,
width: args.width,
height: args.height,
});
return { ok: true as const };
},
});
async function listNodesForCanvas(
ctx: QueryCtx | MutationCtx,
canvasId: Id<"canvases">,
) {
return await ctx.db
.query("nodes")
.withIndex("by_canvas", (q) => q.eq("canvasId", canvasId))
.collect();
}
function collectStorageIds(
nodes: Array<{ data: unknown }>,
): Array<Id<"_storage">> {
const ids = new Set<Id<"_storage">>();
for (const node of nodes) {
const data = node.data as Record<string, unknown> | undefined;
const storageId = data?.storageId;
const previewStorageId = data?.previewStorageId;
if (typeof storageId === "string" && storageId.length > 0) {
ids.add(storageId as Id<"_storage">);
}
if (typeof previewStorageId === "string" && previewStorageId.length > 0) {
ids.add(previewStorageId as Id<"_storage">);
}
}
return [...ids];
}
async function collectOwnedImageStorageIdsForUser(
ctx: QueryCtx | MutationCtx,
userId: string,
): Promise<Set<Id<"_storage">>> {
const canvases = await ctx.db
.query("canvases")
.withIndex("by_owner", (q) => q.eq("ownerId", userId))
.collect();
if (canvases.length === 0) {
return new Set();
}
const imageNodesByCanvas = await Promise.all(
canvases.map((canvas) =>
ctx.db
.query("nodes")
.withIndex("by_canvas_type", (q) => q.eq("canvasId", canvas._id).eq("type", "image"))
.collect(),
),
);
const imageStorageIds = new Set<Id<"_storage">>();
for (const nodes of imageNodesByCanvas) {
for (const node of nodes) {
const data = node.data as Record<string, unknown> | undefined;
const storageId = data?.storageId;
const previewStorageId = data?.previewStorageId;
if (typeof storageId === "string" && storageId.length > 0) {
imageStorageIds.add(storageId as Id<"_storage">);
}
if (typeof previewStorageId === "string" && previewStorageId.length > 0) {
imageStorageIds.add(previewStorageId as Id<"_storage">);
}
}
}
return imageStorageIds;
}