@paroicms/server-image-cache-engine
Version:
The image variant engine that we use at Paroi.
520 lines • 18.5 kB
JavaScript
import { parseTimeWithUnit } from "@paroicms/internal-anywhere-lib";
import { parseSqliteDateTime } from "@paroicms/internal-server-lib";
import { isDef } from "@paroicms/public-anywhere-lib";
import { type } from "arktype";
import { BatchStoredCacheItemRowAT, ImageVariantRowAT, ImageVariantWithBinaryByOwnerRowAT, ImageVariantWithBinaryRowAT, } from "./queries-at.js";
const ExistingImageCacheRowAT = type({
id: "number",
"+": "reject",
}).pipe((r) => ({
id: String(r.id),
}));
const InsertedImageCacheRowAT = type({
id: "number",
modifiedAt: "string|number|Date",
"+": "reject",
}).pipe((r) => ({
id: String(r.id),
modifiedAt: parseSqliteDateTime(r.modifiedAt),
}));
const ModifiedAtRowAT = type({
modifiedAt: "string|number|Date",
"+": "reject",
}).pipe((r) => ({
modifiedAt: parseSqliteDateTime(r.modifiedAt),
}));
export async function insertIntoImageVariant(ctx, data, owners) {
const { cn } = ctx;
const mTypeRSize = makeMTypeRSizeKey(data);
const existingRow = await cn("PaImageCache")
.select("id")
.where({
mediaId: data.mediaId,
mTypeRSize,
})
.first();
let imageCacheId = existingRow ? ExistingImageCacheRowAT.assert(existingRow).id : undefined;
let modifiedAt;
if (imageCacheId === undefined) {
const [inserted] = await cn("PaImageCache")
.insert({
resourceVersion: data.resourceVersion,
mediaId: data.mediaId,
mTypeRSize,
mediaType: data.mediaType,
rawWidth: data.rawWidth,
rawHeight: data.rawHeight,
weightB: data.weightB,
binaryFile: data.binaryFile,
slug: data.slug,
modifiedAt: cn.fn.now(),
})
.returning(["id", "modifiedAt"]);
const newValues = InsertedImageCacheRowAT.assert(inserted);
imageCacheId = newValues.id;
modifiedAt = newValues.modifiedAt;
}
else {
await cn("PaImageCache")
.where({
id: imageCacheId,
})
.update({
weightB: data.weightB,
binaryFile: data.binaryFile,
modifiedAt: cn.fn.now(),
});
const row = await cn("PaImageCache")
.select("modifiedAt")
.where("id", imageCacheId)
.first();
if (!row)
throw new Error(`missing cache row: ${imageCacheId}`);
modifiedAt = ModifiedAtRowAT.assert(row).modifiedAt;
}
await insertIntoPaImageCacheOwner(ctx, { imageCacheId, owners });
return { imageCacheId, lastModified: modifiedAt.toISOString() };
}
export async function insertIntoPaImageCacheOwner(ctx, input) {
const { cn } = ctx;
const { imageCacheId, owners } = input;
if (owners.length === 0)
return;
for (const { ownerHandle, rawResizeR } of owners) {
await cn("PaImageCacheOwner")
.insert({
imageCacheId,
ownerHandle,
rawResizeR,
})
.onConflict(["imageCacheId", "ownerHandle", "rawResizeR"])
.ignore();
}
}
export async function fetchImageVariantWithBinary(ctx, input) {
const { mediaId } = input;
const row = await ctx
.cn("PaImageCache as v")
.select([
"v.id",
"v.resourceVersion",
"v.rawWidth",
"v.rawHeight",
"v.mediaType",
"v.weightB",
"v.binaryFile",
"v.modifiedAt",
"v.slug",
])
.where({
"v.mediaId": mediaId,
"v.mTypeRSize": makeMTypeRSizeKey(input),
})
.first();
if (!row)
return;
const { binaryFile, ...rest } = row;
const { imageCacheId, variant, modifiedAt, weightB } = ImageVariantWithBinaryRowAT.assert({
...rest,
mediaId,
});
return {
id: imageCacheId,
variant: {
...variant,
weightB,
binaryFile: binaryFile,
lastModified: modifiedAt.toISOString(),
},
};
}
export async function fetchImageVariantWithBinaryByOwner(ctx, input) {
const { mediaId, mediaType, ownerHandle, rawResizeR } = input;
const row = await ctx
.cn("PaImageCache as v")
.innerJoin("PaImageCacheOwner as o", "o.imageCacheId", "v.id")
.select([
"v.id",
"v.mediaId",
"v.resourceVersion",
"v.rawWidth",
"v.rawHeight",
"v.mediaType",
"v.weightB",
"v.binaryFile",
"v.modifiedAt",
"v.slug",
])
.where({
"v.mediaId": mediaId,
"v.mediaType": mediaType,
"o.ownerHandle": ownerHandle,
"o.rawResizeR": rawResizeR,
})
.first();
if (!row)
return;
const { binaryFile, ...rest } = row;
const { imageCacheId: id, modifiedAt, variant, weightB, } = ImageVariantWithBinaryByOwnerRowAT.assert(rest);
return {
id,
variant: {
...variant,
weightB,
binaryFile: binaryFile,
lastModified: modifiedAt.toISOString(),
},
};
}
export async function deleteVariants(ctx, options) {
const { cn } = ctx;
if ("mediaIds" in options) {
if (ctx.debugMode) {
await logBeforeDeleteCacheImages(ctx, options.mediaIds);
}
await cn("PaImageCache").whereIn("mediaId", options.mediaIds).delete();
}
else {
if (ctx.debugMode) {
await logBeforeDeleteCacheImages(ctx, [options.mediaId]);
}
await cn("PaImageCache").where("mediaId", options.mediaId).delete();
}
}
/**
* @returns the list of cache ownerHandles that were deleted
*/
export async function deleteVariantsByDependencies(ctx, options) {
const { cn, logger } = ctx;
const count = await cn("PaImageCacheOwner as o")
.whereIn("o.ownerHandle", options.ownerHandles)
.delete();
logger.debug(`[image-cache] Deleted ${count} cache owner(s) for [${options.ownerHandles.join("] [")}]`);
await deleteOrphanVariants(ctx);
}
const OrphanVariantRowAT = type({
id: "number",
mediaId: "string",
"+": "reject",
}).pipe((r) => ({
id: String(r.id),
mediaId: r.mediaId,
}));
/**
* Delete the images that have no ownerHandle and that have not been used recently.
* @returns the number of cache identifiers that were deleted
*/
export async function deleteOrphanVariants(ctx) {
const { cn, timeToIdle } = ctx;
if (timeToIdle === undefined)
return 0;
const days = parseTimeWithUnit(timeToIdle).delay;
const rows = await cn("PaImageCache")
.select("id", "mediaId")
.whereNotExists(cn("PaImageCacheOwner as k").select(1).whereRaw("k.imageCacheId = PaImageCache.id"))
.andWhereRaw(`date(modifiedAt) < date(current_timestamp, '-${days} day')`);
if (rows.length === 0)
return 0;
const orphanRows = rows.map((row) => OrphanVariantRowAT.assert(row));
// DEBUG: Log orphaned images before deletion (at warning level for production visibility)
await debugLogOrphanedImages(ctx);
if (ctx.debugMode) {
await logBeforeDeleteCacheImages(ctx, orphanRows.map((row) => row.mediaId));
}
const result = await cn("PaImageCache")
.whereIn("id", orphanRows.map((row) => row.id))
.delete();
ctx.logger.debug(`[image-cache] Deleted ${result} orphaned image cache variants (older than ${days} days)`);
return result;
}
const PreviousOwnedRowAT = type({
imageCacheId: "number",
rawResizeR: "string",
"+": "reject",
}).pipe((r) => ({
imageCacheId: String(r.imageCacheId),
rawResizeR: r.rawResizeR,
}));
export async function associateOwnerToImages(ctx, { ownerHandle, cacheImages, isHandleReusable, }) {
const { cn, logger } = ctx;
await cn.transaction(async (tx) => {
const prevRows = await tx("PaImageCacheOwner")
.select(["imageCacheId", "rawResizeR"])
.where({ ownerHandle });
const prevOwnedItems = new Map(prevRows.map((row) => {
const item = PreviousOwnedRowAT.assert(row);
return [makeCacheImageKey(item), item];
}));
const newCacheImages = new Map(cacheImages.map((i) => [makeCacheImageKey(i), i.rawResizeR]));
// Insert new items
const toInsert = cacheImages.filter((item) => !prevOwnedItems.has(makeCacheImageKey(item)));
if (toInsert.length > 0) {
await tx("PaImageCacheOwner").insert(toInsert.map(({ imageCacheId, rawResizeR }) => ({
imageCacheId,
ownerHandle,
rawResizeR,
})));
}
// Delete obsolete items
if (!isHandleReusable) {
const toDelete = Array.from(prevOwnedItems.entries())
.filter(([key]) => !newCacheImages.has(key))
.map(([_, item]) => item);
if (toDelete.length > 0) {
// DEBUG: Log details about owner associations being deleted
await debugLogOwnerDissociation(ctx, tx, {
ownerHandle,
toDelete,
newCacheImages: Array.from(newCacheImages.keys()),
prevOwnedItems: Array.from(prevOwnedItems.keys()),
});
const count = await tx("PaImageCacheOwner")
.where({ ownerHandle })
.whereIn(["imageCacheId", "rawResizeR"], toDelete.map((item) => [item.imageCacheId, item.rawResizeR]))
.delete();
logger.debug(`[image-cache] Deleted ${count} cache owner(s) for [${ownerHandle}]`);
}
}
});
}
function makeCacheImageKey({ imageCacheId, rawResizeR, }) {
return `${imageCacheId}:${rawResizeR}`;
}
export async function fetchStoredCacheItems(ctx, ownerHandle) {
const { cn } = ctx;
const query = cn("PaImageCache as v")
.select([
"v.id",
"v.mediaId",
"v.mediaType",
"v.rawWidth",
"v.rawHeight",
"v.resourceVersion",
"v.slug",
])
.join("PaImageCacheOwner as o", "o.imageCacheId", "v.id")
.where("o.ownerHandle", ownerHandle);
const rows = await query;
return rows.map((row) => {
const { imageCacheId, variant } = ImageVariantRowAT.assert(row);
return {
imageCacheId,
variant,
alreadyOwned: true,
};
});
}
export async function fetchBatchOfStoredCacheItems(ctx, groups, alreadyOwnedBy) {
if (groups.length === 0)
return [];
const { cn } = ctx;
const query = cn("PaImageCache as v")
.select([
"v.id",
"v.mediaId",
"v.mediaType",
"v.rawWidth",
"v.rawHeight",
"v.resourceVersion",
"v.slug",
"o.ownerHandle",
])
.leftJoin("PaImageCacheOwner as o", {
"o.imageCacheId": "v.id",
"o.ownerHandle": cn.raw("?", [alreadyOwnedBy]),
});
query.where((qb) => {
for (const { mediaIds, requestedVariants } of groups) {
if (mediaIds.length === 0 || requestedVariants.length === 0)
continue;
qb.orWhere((subQb) => {
subQb.whereIn("v.mediaId", mediaIds).whereIn("v.mTypeRSize", requestedVariants.map((v) => makeMTypeRSizeKey(v)));
});
}
});
const rows = await query;
return rows.map((row) => {
const { imageCacheId, variant, ownerHandle } = BatchStoredCacheItemRowAT.assert(row);
return {
imageCacheId,
variant,
alreadyOwned: isDef(ownerHandle),
};
});
}
export async function clearAll({ cn, logger }) {
await cn("PaImageCacheOwner").delete();
await cn("PaImageCache").delete();
logger.debug("[image-cache] Cleared all image cache entries");
}
const UsedRawSizeRowAT = type({
rawResizeR: "string",
rawWidth: "number",
rawHeight: "number",
"+": "reject",
});
export function makeMTypeRSizeKey(image) {
return `${image.rawWidth}x${image.rawHeight}:${image.mediaType}`;
}
export async function fetchUsedRawSizesByMedia(ctx, mediaId) {
const { cn } = ctx;
const rows = await cn("PaImageCache as v")
.innerJoin("PaImageCacheOwner as o", "o.imageCacheId", "v.id")
.select(["o.rawResizeR", "v.rawWidth", "v.rawHeight"])
.where("v.mediaId", mediaId)
.orderBy("v.rawWidth", "asc")
.orderBy("v.rawHeight", "asc")
.distinct();
return rows.map((row) => {
const { rawResizeR, rawWidth, rawHeight } = UsedRawSizeRowAT.assert(row);
return {
rawResizeR: rawResizeR,
rawSizeName: `${rawWidth}x${rawHeight}`,
};
});
}
const CacheEntryWithOwnerRowAT = type({
id: "number",
mediaId: "string",
resourceVersion: "string",
mTypeRSize: "string",
modifiedAt: "string|number|Date",
ownerHandle: "string|null",
rawResizeR: "string|null",
"+": "reject",
}).pipe((r) => ({
id: String(r.id),
mediaId: r.mediaId,
resourceVersion: r.resourceVersion,
mTypeRSize: r.mTypeRSize,
modifiedAt: parseSqliteDateTime(r.modifiedAt),
ownerHandle: r.ownerHandle ?? undefined,
rawResizeR: r.rawResizeR ?? undefined,
}));
async function logBeforeDeleteCacheImages(ctx, mediaIds) {
if (!ctx.debugMode)
return;
if (mediaIds.length === 0)
return;
const { cn } = ctx;
const rows = await cn("PaImageCache as v")
.select([
"v.id",
"v.mediaId",
"v.resourceVersion",
"v.mTypeRSize",
"v.modifiedAt",
"o.ownerHandle",
"o.rawResizeR",
])
.leftJoin("PaImageCacheOwner as o", "o.imageCacheId", "v.id")
.whereIn("v.mediaId", mediaIds)
.orderBy("v.id");
const validatedRows = rows.map((row) => CacheEntryWithOwnerRowAT.assert(row));
const entriesMap = new Map();
for (const row of validatedRows) {
const { id, mediaId, resourceVersion, mTypeRSize, modifiedAt, ownerHandle, rawResizeR } = row;
let entry = entriesMap.get(id);
if (!entry) {
entry = {
id,
mediaId,
resourceVersion,
mTypeRSize,
modifiedAt,
owners: [],
};
entriesMap.set(id, entry);
}
if (ownerHandle && rawResizeR) {
entry.owners.push({ ownerHandle, rawResizeR });
}
}
for (const entry of entriesMap.values()) {
const ownersStr = entry.owners.length > 0
? entry.owners.map((o) => `(${o.ownerHandle} ${o.rawResizeR})`).join(", ")
: "no owners";
ctx.logger.debug(`[image-cache] DELETE image cache [${entry.id}][${entry.mediaId}][${entry.resourceVersion}] ${entry.mTypeRSize} - ${entry.modifiedAt.toISOString()} - owned by: ${ownersStr}`);
}
}
// ============================================================================
// DEBUG FUNCTIONS - Easy to remove later
// These functions help diagnose the image orphaning bug (#231).
// They log at WARNING level to ensure visibility in production logs.
// ============================================================================
const DebugImageInfoRowAT = type({
id: "number",
mediaId: "string",
mTypeRSize: "string",
modifiedAt: "string|number|Date",
"+": "reject",
}).pipe((r) => ({
id: String(r.id),
mediaId: r.mediaId,
mTypeRSize: r.mTypeRSize,
modifiedAt: parseSqliteDateTime(r.modifiedAt),
}));
/**
* DEBUG: Log when owner associations are being deleted.
* This helps diagnose the orphaning bug where images lose their owners unexpectedly.
*/
async function debugLogOwnerDissociation(ctx, tx, data) {
const { logger } = ctx;
const { ownerHandle, toDelete, newCacheImages, prevOwnedItems } = data;
// Get image details for the items being deleted
const imageCacheIds = toDelete.map((item) => item.imageCacheId);
const rows = await tx("PaImageCache")
.select(["id", "mediaId", "mTypeRSize", "modifiedAt"])
.whereIn("id", imageCacheIds);
const imageInfoMap = new Map(rows.map((row) => {
const info = DebugImageInfoRowAT.assert(row);
return [info.id, info];
}));
// Build a detailed log message
const deletedDetails = toDelete.map((item) => {
const info = imageInfoMap.get(item.imageCacheId);
if (info) {
return `[${item.imageCacheId}] ${info.mediaId} ${info.mTypeRSize} (${item.rawResizeR})`;
}
return `[${item.imageCacheId}] (${item.rawResizeR})`;
});
logger.warn(`[image-cache][DEBUG] Owner dissociation for [${ownerHandle}]: ` +
`removing ${toDelete.length} association(s). ` +
`Previous: [${prevOwnedItems.join(", ")}]. ` +
`New: [${newCacheImages.join(", ")}]. ` +
`Deleted: ${deletedDetails.join("; ")}`);
}
/**
* DEBUG: Log when images become orphaned (no owners left).
* Call this before deleting orphan variants to see what's being cleaned up.
*/
export async function debugLogOrphanedImages(ctx) {
const { cn, logger, timeToIdle } = ctx;
if (timeToIdle === undefined)
return;
const days = parseTimeWithUnit(timeToIdle).delay;
// Find orphaned images that will be deleted
const rows = await cn("PaImageCache")
.select(["id", "mediaId", "mTypeRSize", "modifiedAt"])
.whereNotExists(cn("PaImageCacheOwner as k").select(1).whereRaw("k.imageCacheId = PaImageCache.id"))
.andWhereRaw(`date(modifiedAt) < date(current_timestamp, '-${days} day')`);
if (rows.length === 0)
return;
const orphanedImages = rows.map((row) => DebugImageInfoRowAT.assert(row));
// Group by mediaId for more readable output
const byMediaId = new Map();
for (const img of orphanedImages) {
const list = byMediaId.get(img.mediaId) ?? [];
list.push(img);
byMediaId.set(img.mediaId, list);
}
const summary = Array.from(byMediaId.entries())
.map(([mediaId, images]) => {
const sizes = images.map((img) => img.mTypeRSize).join(", ");
return `${mediaId}: [${sizes}]`;
})
.join("; ");
logger.warn(`[image-cache][DEBUG] About to delete ${orphanedImages.length} orphaned image(s) ` +
`(no owners, older than ${days} days): ${summary}`);
}
//# sourceMappingURL=queries.js.map