@michaelthielemann/kestrel-images-default 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +35 -0
- package/dist/generate.d.ts +16 -0
- package/dist/generate.js +22 -0
- package/dist/impl.d.ts +95 -0
- package/dist/impl.js +584 -0
- package/dist/module.d.ts +299 -0
- package/dist/module.js +208 -0
- package/dist/sizes.d.ts +49 -0
- package/dist/sizes.js +47 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +47 -0
package/dist/impl.js
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
import { mkdir, stat, utimes, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, extname, relative, resolve as resolvePath, sep } from "node:path";
|
|
3
|
+
import { failure } from "@michaelthielemann/kestrel/errors";
|
|
4
|
+
import { err, isErr, ok } from "@michaelthielemann/kestrel/result";
|
|
5
|
+
import { eligible, render } from "./generate.js";
|
|
6
|
+
import { DEFAULT_SIZES, mergeSizes, sizeSchema, spec } from "./sizes.js";
|
|
7
|
+
export const SIZES = "images_sizes";
|
|
8
|
+
export const VARIANTS = "images_variants";
|
|
9
|
+
export const JOBS = "images_jobs";
|
|
10
|
+
export const DEFAULT_MAX_ATTEMPTS = 5;
|
|
11
|
+
function errorMessage(cause) {
|
|
12
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
13
|
+
}
|
|
14
|
+
function parseSize(raw) {
|
|
15
|
+
const parsed = sizeSchema.safeParse(raw);
|
|
16
|
+
if (parsed.success)
|
|
17
|
+
return ok(parsed.data);
|
|
18
|
+
const issue = parsed.error.issues[0];
|
|
19
|
+
return err(failure("VALIDATION", `images: ${issue?.path.join(".") ?? "size"}: ${issue?.message ?? "invalid size"}`));
|
|
20
|
+
}
|
|
21
|
+
export async function createImages(config, deps, now = Date.now) {
|
|
22
|
+
const { blobs, db, logger } = deps;
|
|
23
|
+
const collections = [
|
|
24
|
+
[SIZES, { name: "string", width: "number", height: "number", fit: "string", format: "string", quality: "number", source: "string", updatedAt: "number" }],
|
|
25
|
+
[VARIANTS, { mediaId: "string", size: "string", spec: "string", width: "number", height: "number", format: "string", key: "string", bytes: "number", state: "string", error: "json", attempts: "number", updatedAt: "number" }],
|
|
26
|
+
[JOBS, { state: "string", total: "number", done: "number", failed: "number", cursor: "string", startedAt: "number", updatedAt: "number", finishedAt: "json", error: "json" }],
|
|
27
|
+
];
|
|
28
|
+
for (const [collection, schema] of collections) {
|
|
29
|
+
const prepared = await db.ensureCollection(collection, schema);
|
|
30
|
+
if (isErr(prepared))
|
|
31
|
+
throw new Error(`images/default: cannot prepare collection "${collection}": ${prepared.error.message}`);
|
|
32
|
+
}
|
|
33
|
+
const baseSizes = config.sizes ?? DEFAULT_SIZES;
|
|
34
|
+
const baseSource = config.sizes ? "config" : "default";
|
|
35
|
+
const maxAttempts = config.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
36
|
+
// sizes are declared in code (config or images.register), so a stored copy would outlive the code
|
|
37
|
+
// that defined it; earlier versions persisted them, and those rows are dropped once here.
|
|
38
|
+
const staleSizeRows = await db.deleteMany(SIZES, {});
|
|
39
|
+
if (isErr(staleSizeRows))
|
|
40
|
+
throw new Error(`images/default: cannot drop persisted size rows: ${staleSizeRows.error.message}`);
|
|
41
|
+
if (staleSizeRows.value > 0)
|
|
42
|
+
logger.info(`images/default: dropped ${staleSizeRows.value} persisted size rows, sizes are rebuilt from config and images.register`);
|
|
43
|
+
const initialSizes = mergeSizes(baseSizes, baseSource, []);
|
|
44
|
+
if (isErr(initialSizes))
|
|
45
|
+
throw new Error(`images/default: ${initialSizes.error.message}`);
|
|
46
|
+
let effective = initialSizes.value;
|
|
47
|
+
// until this process has seen a register() call it cannot tell a variant of a size the code no
|
|
48
|
+
// longer declares from a variant of a size that simply has not been registered yet.
|
|
49
|
+
let registrySeen = false;
|
|
50
|
+
let closed = false;
|
|
51
|
+
const latestJob = await db.findMany(JOBS, {}, { sort: { startedAt: "desc" }, limit: 1 });
|
|
52
|
+
if (isErr(latestJob))
|
|
53
|
+
throw new Error(`images/default: cannot read the sync job: ${latestJob.error.message}`);
|
|
54
|
+
let currentJobId = latestJob.value.items[0]?.id ?? null;
|
|
55
|
+
let loopActive = false;
|
|
56
|
+
let stopRequested = false;
|
|
57
|
+
let idleWaiters = [];
|
|
58
|
+
const notifyIdle = () => {
|
|
59
|
+
const waiters = idleWaiters;
|
|
60
|
+
idleWaiters = [];
|
|
61
|
+
for (const resolve of waiters)
|
|
62
|
+
resolve();
|
|
63
|
+
};
|
|
64
|
+
const idle = () => (loopActive ? new Promise((resolve) => idleWaiters.push(resolve)) : Promise.resolve());
|
|
65
|
+
async function orphanedVariants(accounted) {
|
|
66
|
+
const total = await db.count(VARIANTS, {});
|
|
67
|
+
if (isErr(total))
|
|
68
|
+
return total;
|
|
69
|
+
if (total.value === accounted)
|
|
70
|
+
return ok({ sizes: [], variants: 0 });
|
|
71
|
+
const declared = new Set(effective.map((size) => size.name));
|
|
72
|
+
const counts = new Map();
|
|
73
|
+
for (let offset = 0;; offset += 500) {
|
|
74
|
+
const page = await db.findMany(VARIANTS, {}, { sort: { id: "asc" }, limit: 500, offset });
|
|
75
|
+
if (isErr(page))
|
|
76
|
+
return page;
|
|
77
|
+
for (const row of page.value.items) {
|
|
78
|
+
if (!declared.has(row.size))
|
|
79
|
+
counts.set(row.size, (counts.get(row.size) ?? 0) + 1);
|
|
80
|
+
}
|
|
81
|
+
if (page.value.items.length < 500)
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
return ok({ sizes: [...counts.keys()].sort(), variants: [...counts.values()].reduce((sum, n) => sum + n, 0) });
|
|
85
|
+
}
|
|
86
|
+
// concurrent callers for the same mediaId (an upload event and the sync loop both reaching it, say)
|
|
87
|
+
// must not read-then-write the variant rows independently, or they duplicate them; the second
|
|
88
|
+
// caller instead awaits the first caller's in-flight render.
|
|
89
|
+
const inFlight = new Map();
|
|
90
|
+
function generate(mediaId) {
|
|
91
|
+
const running = inFlight.get(mediaId);
|
|
92
|
+
if (running)
|
|
93
|
+
return running;
|
|
94
|
+
const promise = generateOnce(mediaId);
|
|
95
|
+
inFlight.set(mediaId, promise);
|
|
96
|
+
void promise.finally(() => inFlight.delete(mediaId));
|
|
97
|
+
return promise;
|
|
98
|
+
}
|
|
99
|
+
async function renderVariant(mediaId, size, original, previousKey) {
|
|
100
|
+
let rendered;
|
|
101
|
+
try {
|
|
102
|
+
rendered = await render(original.data, size);
|
|
103
|
+
}
|
|
104
|
+
catch (cause) {
|
|
105
|
+
return err(failure("VALIDATION", errorMessage(cause), { cause }));
|
|
106
|
+
}
|
|
107
|
+
const key = `${config.prefix}${mediaId}/${size.name}.${rendered.ext}`;
|
|
108
|
+
// a format change moves the variant to a new key; the old blob would otherwise be orphaned
|
|
109
|
+
// with nothing referencing it.
|
|
110
|
+
if (previousKey !== "" && previousKey !== key) {
|
|
111
|
+
const dropped = await blobs.remove(previousKey);
|
|
112
|
+
if (isErr(dropped))
|
|
113
|
+
return dropped;
|
|
114
|
+
}
|
|
115
|
+
const stored = await blobs.put(key, { data: rendered.data, contentType: rendered.contentType });
|
|
116
|
+
if (isErr(stored))
|
|
117
|
+
return stored;
|
|
118
|
+
return ok({ spec: spec(size), width: rendered.width, height: rendered.height, format: rendered.format, key, bytes: rendered.data.byteLength, state: "done", error: null });
|
|
119
|
+
}
|
|
120
|
+
async function generateOnce(mediaId) {
|
|
121
|
+
const media = await db.findOne(config.media.collection, { id: mediaId });
|
|
122
|
+
if (isErr(media))
|
|
123
|
+
return media;
|
|
124
|
+
if (media.value === null || !eligible(media.value.contentType))
|
|
125
|
+
return ok([]);
|
|
126
|
+
const stored = await db.findMany(VARIANTS, { mediaId }, { limit: effective.length + 50 });
|
|
127
|
+
if (isErr(stored))
|
|
128
|
+
return stored;
|
|
129
|
+
const byName = new Map(stored.value.items.map((row) => [row.size, row]));
|
|
130
|
+
const upToDate = (size) => {
|
|
131
|
+
const row = byName.get(size.name);
|
|
132
|
+
return row !== undefined && row.state === "done" && row.spec === spec(size);
|
|
133
|
+
};
|
|
134
|
+
// a size whose definition changed is new work, so the exhausted attempt count of the old spec
|
|
135
|
+
// must not keep the variant quarantined.
|
|
136
|
+
const exhausted = (size) => {
|
|
137
|
+
const row = byName.get(size.name);
|
|
138
|
+
return row !== undefined && row.state === "failed" && row.spec === spec(size);
|
|
139
|
+
};
|
|
140
|
+
let original = null;
|
|
141
|
+
if (effective.some((size) => !upToDate(size) && !exhausted(size))) {
|
|
142
|
+
const found = await blobs.get(media.value.key);
|
|
143
|
+
if (isErr(found))
|
|
144
|
+
return found;
|
|
145
|
+
original = found.value;
|
|
146
|
+
}
|
|
147
|
+
const results = [];
|
|
148
|
+
for (const size of effective) {
|
|
149
|
+
const row = byName.get(size.name);
|
|
150
|
+
if (upToDate(size) || exhausted(size)) {
|
|
151
|
+
results.push(row);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
// a redefined size is new work, so lifting the quarantine also restores the full attempt budget
|
|
155
|
+
const attempts = (row !== undefined && row.state === "failed" ? 0 : (row?.attempts ?? 0)) + 1;
|
|
156
|
+
const previousKey = row?.key ?? "";
|
|
157
|
+
// the row is written as "pending" before the render so a crash mid-render leaves a retryable
|
|
158
|
+
// state rather than a stale "done", and read() falls back to the original meanwhile.
|
|
159
|
+
const pending = { spec: spec(size), width: row?.width ?? 0, height: row?.height ?? 0, format: row?.format ?? "", key: previousKey, bytes: row?.bytes ?? 0, state: "pending", error: null, attempts, updatedAt: now() };
|
|
160
|
+
const current = row ? await db.updateOne(VARIANTS, row.id, pending) : await db.createOne(VARIANTS, { mediaId, size: size.name, ...pending });
|
|
161
|
+
if (isErr(current))
|
|
162
|
+
return current;
|
|
163
|
+
const failedState = attempts >= maxAttempts ? "failed" : "error";
|
|
164
|
+
let patch;
|
|
165
|
+
if (original === null) {
|
|
166
|
+
patch = { ...pending, state: failedState, error: "original blob missing", updatedAt: now() };
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
const written = await renderVariant(mediaId, size, original, previousKey);
|
|
170
|
+
patch = isErr(written) ? { ...pending, state: failedState, error: written.error.message, updatedAt: now() } : { ...written.value, attempts, updatedAt: now() };
|
|
171
|
+
}
|
|
172
|
+
if (patch.state === "failed")
|
|
173
|
+
logger.error(`images: variant ${size.name} for media/${mediaId} gave up after ${attempts} attempts`, { mediaId, size: size.name, error: patch.error });
|
|
174
|
+
const updated = await db.updateOne(VARIANTS, current.value.id, patch);
|
|
175
|
+
if (isErr(updated))
|
|
176
|
+
return updated;
|
|
177
|
+
results.push(updated.value);
|
|
178
|
+
}
|
|
179
|
+
return ok(results);
|
|
180
|
+
}
|
|
181
|
+
async function loopOnce() {
|
|
182
|
+
const found = await db.findOne(JOBS, { id: currentJobId });
|
|
183
|
+
if (isErr(found))
|
|
184
|
+
return found;
|
|
185
|
+
const job = found.value;
|
|
186
|
+
if (!job)
|
|
187
|
+
return ok("done");
|
|
188
|
+
const filter = job.cursor === "" ? {} : { id: { gt: job.cursor } };
|
|
189
|
+
const page = await db.findMany(config.media.collection, filter, { sort: { id: "asc" }, limit: config.chunk });
|
|
190
|
+
if (isErr(page))
|
|
191
|
+
return page;
|
|
192
|
+
if (page.value.items.length === 0) {
|
|
193
|
+
const closedJob = await db.updateOne(JOBS, job.id, { state: "done", finishedAt: now(), updatedAt: now() });
|
|
194
|
+
if (isErr(closedJob))
|
|
195
|
+
return closedJob;
|
|
196
|
+
return ok("done");
|
|
197
|
+
}
|
|
198
|
+
let done = job.done;
|
|
199
|
+
let failed = job.failed;
|
|
200
|
+
for (const media of page.value.items) {
|
|
201
|
+
const variants = await generate(media.id);
|
|
202
|
+
if (isErr(variants))
|
|
203
|
+
return variants;
|
|
204
|
+
if (variants.value.some((v) => v.state === "error" || v.state === "failed"))
|
|
205
|
+
failed += 1;
|
|
206
|
+
else
|
|
207
|
+
done += 1;
|
|
208
|
+
// per image, not per chunk: a long chunk would otherwise look stalled to /admin/images/status
|
|
209
|
+
// and trip the staleAfterMs takeover.
|
|
210
|
+
const progressed = await db.updateOne(JOBS, job.id, { done, failed, updatedAt: now() });
|
|
211
|
+
if (isErr(progressed))
|
|
212
|
+
return progressed;
|
|
213
|
+
}
|
|
214
|
+
const cursor = page.value.items[page.value.items.length - 1].id;
|
|
215
|
+
const finished = page.value.items.length < config.chunk;
|
|
216
|
+
const advanced = await db.updateOne(JOBS, job.id, finished ? { done, failed, cursor, state: "done", finishedAt: now(), updatedAt: now() } : { done, failed, cursor, updatedAt: now() });
|
|
217
|
+
if (isErr(advanced))
|
|
218
|
+
return advanced;
|
|
219
|
+
return ok(finished ? "done" : "more");
|
|
220
|
+
}
|
|
221
|
+
async function recordJobFailure(message) {
|
|
222
|
+
if (currentJobId) {
|
|
223
|
+
try {
|
|
224
|
+
const marked = await db.updateOne(JOBS, currentJobId, { state: "error", error: message, updatedAt: now() });
|
|
225
|
+
if (isErr(marked))
|
|
226
|
+
logger.error("images: could not record the sync job failure", { job: currentJobId, error: marked.error.message });
|
|
227
|
+
}
|
|
228
|
+
catch (cause) {
|
|
229
|
+
logger.error("images: could not record the sync job failure", { job: currentJobId, error: errorMessage(cause) });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
logger.error("images: sync job failed", { error: message });
|
|
233
|
+
}
|
|
234
|
+
function runLoop() {
|
|
235
|
+
setImmediate(() => {
|
|
236
|
+
void (async () => {
|
|
237
|
+
if (stopRequested) {
|
|
238
|
+
loopActive = false;
|
|
239
|
+
notifyIdle();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
const outcome = await loopOnce();
|
|
244
|
+
if (isErr(outcome))
|
|
245
|
+
await recordJobFailure(outcome.error.message);
|
|
246
|
+
else if (outcome.value === "more" && !stopRequested) {
|
|
247
|
+
runLoop();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
catch (cause) {
|
|
252
|
+
await recordJobFailure(errorMessage(cause));
|
|
253
|
+
}
|
|
254
|
+
loopActive = false;
|
|
255
|
+
notifyIdle();
|
|
256
|
+
})();
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
function startLoop() {
|
|
260
|
+
if (loopActive)
|
|
261
|
+
return;
|
|
262
|
+
loopActive = true;
|
|
263
|
+
stopRequested = false;
|
|
264
|
+
runLoop();
|
|
265
|
+
}
|
|
266
|
+
let starting = null;
|
|
267
|
+
async function currentJob() {
|
|
268
|
+
if (!currentJobId)
|
|
269
|
+
return ok(null);
|
|
270
|
+
return db.findOne(JOBS, { id: currentJobId });
|
|
271
|
+
}
|
|
272
|
+
async function startSync() {
|
|
273
|
+
const found = await currentJob();
|
|
274
|
+
if (isErr(found))
|
|
275
|
+
return found;
|
|
276
|
+
const job = found.value;
|
|
277
|
+
if (job && job.state === "running" && now() - job.updatedAt < config.staleAfterMs) {
|
|
278
|
+
return err(failure("CONFLICT", `images: sync job ${job.id} is running`));
|
|
279
|
+
}
|
|
280
|
+
let target;
|
|
281
|
+
if (job && job.state !== "done") {
|
|
282
|
+
const resumed = await db.updateOne(JOBS, job.id, { state: "running", updatedAt: now() });
|
|
283
|
+
if (isErr(resumed))
|
|
284
|
+
return resumed;
|
|
285
|
+
target = resumed.value;
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
const total = await db.count(config.media.collection, {});
|
|
289
|
+
if (isErr(total))
|
|
290
|
+
return total;
|
|
291
|
+
const created = await db.createOne(JOBS, { state: "running", total: total.value, done: 0, failed: 0, cursor: "", startedAt: now(), updatedAt: now(), finishedAt: null, error: null });
|
|
292
|
+
if (isErr(created))
|
|
293
|
+
return created;
|
|
294
|
+
target = created.value;
|
|
295
|
+
currentJobId = target.id;
|
|
296
|
+
}
|
|
297
|
+
startLoop();
|
|
298
|
+
return ok(target);
|
|
299
|
+
}
|
|
300
|
+
const api = {
|
|
301
|
+
async register(input) {
|
|
302
|
+
if (!Array.isArray(input) || input.length === 0)
|
|
303
|
+
return err(failure("VALIDATION", "images: registry must not be empty"));
|
|
304
|
+
const parsed = [];
|
|
305
|
+
for (const raw of input) {
|
|
306
|
+
const size = parseSize(raw);
|
|
307
|
+
if (isErr(size))
|
|
308
|
+
return size;
|
|
309
|
+
parsed.push(size.value);
|
|
310
|
+
}
|
|
311
|
+
const merged = mergeSizes(baseSizes, baseSource, parsed);
|
|
312
|
+
if (isErr(merged))
|
|
313
|
+
return merged;
|
|
314
|
+
effective = merged.value;
|
|
315
|
+
registrySeen = true;
|
|
316
|
+
return ok(merged.value);
|
|
317
|
+
},
|
|
318
|
+
async sizes() {
|
|
319
|
+
return effective;
|
|
320
|
+
},
|
|
321
|
+
async exists(mediaId) {
|
|
322
|
+
const media = await db.findOne(config.media.collection, { id: mediaId });
|
|
323
|
+
if (isErr(media))
|
|
324
|
+
return media;
|
|
325
|
+
return ok(media.value !== null);
|
|
326
|
+
},
|
|
327
|
+
generate,
|
|
328
|
+
sync() {
|
|
329
|
+
if (closed)
|
|
330
|
+
return Promise.resolve(err(failure("CONFLICT", "images: images are shutting down")));
|
|
331
|
+
// two callers arriving in the same tick must not both create a job row; the second awaits the
|
|
332
|
+
// first caller's in-flight start instead.
|
|
333
|
+
if (starting)
|
|
334
|
+
return starting;
|
|
335
|
+
const promise = startSync().finally(() => {
|
|
336
|
+
starting = null;
|
|
337
|
+
});
|
|
338
|
+
starting = promise;
|
|
339
|
+
return promise;
|
|
340
|
+
},
|
|
341
|
+
async resume() {
|
|
342
|
+
if (closed)
|
|
343
|
+
return err(failure("CONFLICT", "images: images are shutting down"));
|
|
344
|
+
const found = await currentJob();
|
|
345
|
+
if (isErr(found))
|
|
346
|
+
return found;
|
|
347
|
+
const job = found.value;
|
|
348
|
+
if (!job || job.state === "done")
|
|
349
|
+
return ok(null);
|
|
350
|
+
if (job.state === "running" && now() - job.updatedAt < config.staleAfterMs)
|
|
351
|
+
return ok(null);
|
|
352
|
+
const target = await db.updateOne(JOBS, job.id, { state: "running", updatedAt: now() });
|
|
353
|
+
if (isErr(target))
|
|
354
|
+
return target;
|
|
355
|
+
startLoop();
|
|
356
|
+
return ok(target.value);
|
|
357
|
+
},
|
|
358
|
+
async prune(names) {
|
|
359
|
+
const uniqueNames = [...new Set(names)];
|
|
360
|
+
const declared = new Set(effective.map((size) => size.name));
|
|
361
|
+
for (const name of uniqueNames) {
|
|
362
|
+
if (declared.has(name))
|
|
363
|
+
return err(failure("VALIDATION", `images: size "${name}" is a declared size, not an orphan`));
|
|
364
|
+
const used = await db.count(VARIANTS, { size: name });
|
|
365
|
+
if (isErr(used))
|
|
366
|
+
return used;
|
|
367
|
+
if (used.value === 0)
|
|
368
|
+
return err(failure("VALIDATION", `images: size "${name}" has no variants`));
|
|
369
|
+
}
|
|
370
|
+
let sizesDeleted = 0;
|
|
371
|
+
let variantsDeleted = 0;
|
|
372
|
+
for (const name of uniqueNames) {
|
|
373
|
+
for (;;) {
|
|
374
|
+
const page = await db.findMany(VARIANTS, { size: name }, { limit: 500 });
|
|
375
|
+
if (isErr(page))
|
|
376
|
+
return page;
|
|
377
|
+
if (page.value.items.length === 0)
|
|
378
|
+
break;
|
|
379
|
+
for (const variant of page.value.items) {
|
|
380
|
+
const removed = await blobs.remove(variant.key);
|
|
381
|
+
if (isErr(removed))
|
|
382
|
+
return removed;
|
|
383
|
+
const dropped = await db.deleteOne(VARIANTS, variant.id);
|
|
384
|
+
if (isErr(dropped))
|
|
385
|
+
return dropped;
|
|
386
|
+
variantsDeleted += 1;
|
|
387
|
+
}
|
|
388
|
+
if (page.value.items.length < 500)
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
sizesDeleted += 1;
|
|
392
|
+
}
|
|
393
|
+
return ok({ sizes: sizesDeleted, variants: variantsDeleted });
|
|
394
|
+
},
|
|
395
|
+
async status() {
|
|
396
|
+
const sizesOut = [];
|
|
397
|
+
let declaredVariants = 0;
|
|
398
|
+
for (const row of effective) {
|
|
399
|
+
const [done, pending, error, failed] = await Promise.all([
|
|
400
|
+
db.count(VARIANTS, { size: row.name, state: "done" }),
|
|
401
|
+
db.count(VARIANTS, { size: row.name, state: "pending" }),
|
|
402
|
+
db.count(VARIANTS, { size: row.name, state: "error" }),
|
|
403
|
+
db.count(VARIANTS, { size: row.name, state: "failed" }),
|
|
404
|
+
]);
|
|
405
|
+
if (isErr(done))
|
|
406
|
+
return done;
|
|
407
|
+
if (isErr(pending))
|
|
408
|
+
return pending;
|
|
409
|
+
if (isErr(error))
|
|
410
|
+
return error;
|
|
411
|
+
if (isErr(failed))
|
|
412
|
+
return failed;
|
|
413
|
+
declaredVariants += done.value + pending.value + error.value + failed.value;
|
|
414
|
+
sizesOut.push({ ...row, used: row.source === "registered", variants: { done: done.value, pending: pending.value, error: error.value, failed: failed.value } });
|
|
415
|
+
}
|
|
416
|
+
const job = await currentJob();
|
|
417
|
+
if (isErr(job))
|
|
418
|
+
return job;
|
|
419
|
+
if (!registrySeen)
|
|
420
|
+
return ok({ sizes: sizesOut, job: job.value, orphaned: { sizes: [], variants: 0 }, registrySeen });
|
|
421
|
+
const orphaned = await orphanedVariants(declaredVariants);
|
|
422
|
+
if (isErr(orphaned))
|
|
423
|
+
return orphaned;
|
|
424
|
+
return ok({ sizes: sizesOut, job: job.value, orphaned: orphaned.value, registrySeen });
|
|
425
|
+
},
|
|
426
|
+
async remove(mediaId) {
|
|
427
|
+
let removed = 0;
|
|
428
|
+
for (;;) {
|
|
429
|
+
const page = await db.findMany(VARIANTS, { mediaId }, { limit: 500 });
|
|
430
|
+
if (isErr(page))
|
|
431
|
+
return page;
|
|
432
|
+
if (page.value.items.length === 0)
|
|
433
|
+
break;
|
|
434
|
+
for (const variant of page.value.items) {
|
|
435
|
+
const dropped = await blobs.remove(variant.key);
|
|
436
|
+
if (isErr(dropped))
|
|
437
|
+
return dropped;
|
|
438
|
+
const deleted = await db.deleteOne(VARIANTS, variant.id);
|
|
439
|
+
if (isErr(deleted))
|
|
440
|
+
return deleted;
|
|
441
|
+
removed += 1;
|
|
442
|
+
}
|
|
443
|
+
if (page.value.items.length < 500)
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
return ok(removed);
|
|
447
|
+
},
|
|
448
|
+
async variantsOf(mediaIds) {
|
|
449
|
+
const map = new Map();
|
|
450
|
+
for (const id of mediaIds)
|
|
451
|
+
map.set(id, []);
|
|
452
|
+
// SQLite caps bound parameters, so a large `in` filter is chunked rather than sent in one query.
|
|
453
|
+
for (let i = 0; i < mediaIds.length; i += 200) {
|
|
454
|
+
const chunk = mediaIds.slice(i, i + 200);
|
|
455
|
+
for (let offset = 0;; offset += 500) {
|
|
456
|
+
const page = await db.findMany(VARIANTS, { mediaId: { in: chunk } }, { limit: 500, offset });
|
|
457
|
+
if (isErr(page))
|
|
458
|
+
return page;
|
|
459
|
+
for (const row of page.value.items)
|
|
460
|
+
map.get(row.mediaId)?.push(row);
|
|
461
|
+
if (page.value.items.length < 500)
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return ok(map);
|
|
466
|
+
},
|
|
467
|
+
async read(mediaId, file) {
|
|
468
|
+
const dot = file.lastIndexOf(".");
|
|
469
|
+
const sizeName = dot === -1 ? file : file.slice(0, dot);
|
|
470
|
+
if (!effective.some((size) => size.name === sizeName))
|
|
471
|
+
return ok(null);
|
|
472
|
+
const media = await db.findOne(config.media.collection, { id: mediaId });
|
|
473
|
+
if (isErr(media))
|
|
474
|
+
return media;
|
|
475
|
+
if (media.value === null)
|
|
476
|
+
return ok(null);
|
|
477
|
+
const found = await db.findOne(VARIANTS, { mediaId, size: sizeName });
|
|
478
|
+
if (isErr(found))
|
|
479
|
+
return found;
|
|
480
|
+
const variant = found.value;
|
|
481
|
+
if (variant && variant.state === "done") {
|
|
482
|
+
const blob = await blobs.get(variant.key);
|
|
483
|
+
if (isErr(blob))
|
|
484
|
+
return blob;
|
|
485
|
+
if (blob.value)
|
|
486
|
+
return ok({ data: blob.value.data, contentType: blob.value.contentType, fallback: false, variant: "done" });
|
|
487
|
+
}
|
|
488
|
+
// a variant that gave up will never become available; serving the full-size original in its
|
|
489
|
+
// place would hide the defect behind a working-looking page forever.
|
|
490
|
+
if (variant && variant.state === "failed")
|
|
491
|
+
return ok(null);
|
|
492
|
+
const original = await blobs.get(media.value.key);
|
|
493
|
+
if (isErr(original))
|
|
494
|
+
return original;
|
|
495
|
+
if (original.value === null)
|
|
496
|
+
return ok(null);
|
|
497
|
+
return ok({ data: original.value.data, contentType: media.value.contentType, fallback: true, variant: "pending" });
|
|
498
|
+
},
|
|
499
|
+
async failure(mediaId, file) {
|
|
500
|
+
const dot = file.lastIndexOf(".");
|
|
501
|
+
const sizeName = dot === -1 ? file : file.slice(0, dot);
|
|
502
|
+
const found = await db.findOne(VARIANTS, { mediaId, size: sizeName });
|
|
503
|
+
if (isErr(found))
|
|
504
|
+
return found;
|
|
505
|
+
const variant = found.value;
|
|
506
|
+
return ok(variant && variant.state === "failed" ? { size: variant.size, attempts: variant.attempts, error: variant.error } : null);
|
|
507
|
+
},
|
|
508
|
+
async exportTo(dir) {
|
|
509
|
+
const base = resolvePath(dir);
|
|
510
|
+
let written = 0;
|
|
511
|
+
let skipped = 0;
|
|
512
|
+
for (let offset = 0;; offset += 500) {
|
|
513
|
+
const page = await db.findMany(VARIANTS, { state: "done" }, { sort: { id: "asc" }, limit: 500, offset });
|
|
514
|
+
if (isErr(page))
|
|
515
|
+
return page;
|
|
516
|
+
for (const variant of page.value.items) {
|
|
517
|
+
const media = await db.findOne(config.media.collection, { id: variant.mediaId });
|
|
518
|
+
if (isErr(media))
|
|
519
|
+
return media;
|
|
520
|
+
if (media.value === null) {
|
|
521
|
+
skipped += 1;
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
const target = resolvePath(base, media.value.folder, `${media.value.filename}.${variant.size}${extname(variant.key)}`);
|
|
525
|
+
const rel = relative(base, target);
|
|
526
|
+
if (rel.startsWith(`..${sep}`) || rel === ".." || rel === "")
|
|
527
|
+
throw new Error(`images/default: export target ${target} escapes ${base}`);
|
|
528
|
+
const existing = await stat(target).catch((cause) => {
|
|
529
|
+
if (cause.code === "ENOENT")
|
|
530
|
+
return null;
|
|
531
|
+
throw cause;
|
|
532
|
+
});
|
|
533
|
+
if (existing && existing.size === variant.bytes && existing.mtimeMs >= variant.updatedAt) {
|
|
534
|
+
skipped += 1;
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
const blob = await blobs.get(variant.key);
|
|
538
|
+
if (isErr(blob))
|
|
539
|
+
return blob;
|
|
540
|
+
if (blob.value === null) {
|
|
541
|
+
skipped += 1;
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
await mkdir(dirname(target), { recursive: true });
|
|
545
|
+
await writeFile(target, blob.value.data);
|
|
546
|
+
const updated = new Date(variant.updatedAt);
|
|
547
|
+
await utimes(target, updated, updated);
|
|
548
|
+
written += 1;
|
|
549
|
+
}
|
|
550
|
+
if (page.value.items.length < 500)
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
return ok({ written, skipped });
|
|
554
|
+
},
|
|
555
|
+
publicPath(mediaId, variant) {
|
|
556
|
+
return `${config.publicPath}/${mediaId}/variants/${variant.size}${extname(variant.key)}`;
|
|
557
|
+
},
|
|
558
|
+
async close() {
|
|
559
|
+
// set before the first await: sync()/resume() called anywhere in this same tick (or later)
|
|
560
|
+
// answer CONFLICT rather than racing a job into "running" that the stopped loop will never pick up.
|
|
561
|
+
closed = true;
|
|
562
|
+
stopRequested = true;
|
|
563
|
+
await idle();
|
|
564
|
+
const found = await currentJob();
|
|
565
|
+
if (isErr(found)) {
|
|
566
|
+
logger.error("images: could not read the sync job while shutting down", { job: currentJobId, error: found.error.message });
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (found.value && found.value.state === "running") {
|
|
570
|
+
const paused = await db.updateOne(JOBS, found.value.id, { state: "paused", updatedAt: now() });
|
|
571
|
+
if (isErr(paused))
|
|
572
|
+
logger.error("images: could not pause the sync job while shutting down", { job: found.value.id, error: paused.error.message });
|
|
573
|
+
}
|
|
574
|
+
},
|
|
575
|
+
};
|
|
576
|
+
idleHooks.set(api, idle);
|
|
577
|
+
return api;
|
|
578
|
+
}
|
|
579
|
+
// test-only hook to await the sync loop's in-process setImmediate chain; not part of the Images
|
|
580
|
+
// interface since production callers never need to observe it.
|
|
581
|
+
const idleHooks = new WeakMap();
|
|
582
|
+
export function whenIdle(images) {
|
|
583
|
+
return (idleHooks.get(images) ?? (() => Promise.resolve()))();
|
|
584
|
+
}
|