@michaelthielemann/kestrel-media-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/dist/impl.js ADDED
@@ -0,0 +1,836 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, stat, utimes, writeFile } from "node:fs/promises";
3
+ import { dirname, extname, relative, resolve as resolvePath, sep } from "node:path";
4
+ import { err, failure, isErr, ok } from "@michaelthielemann/kestrel-contracts/errors";
5
+ import { imageSize } from "./imageSize.js";
6
+ export const COLLECTION = "media_items";
7
+ export const FOLDERS = "media_folders";
8
+ export const TEXT_FIELDS = ["alt", "title", "description"];
9
+ export const SORTABLE = ["createdAt", "filename", "size"];
10
+ export function sortItems(items, sortBy, direction) {
11
+ const field = sortBy ?? "createdAt";
12
+ const sign = (direction ?? (sortBy === undefined ? "desc" : "asc")) === "asc" ? 1 : -1;
13
+ return [...items].sort((a, b) => {
14
+ const av = a[field];
15
+ const bv = b[field];
16
+ if (av < bv)
17
+ return -sign;
18
+ if (av > bv)
19
+ return sign;
20
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
21
+ });
22
+ }
23
+ export function keyFor(folder, filename) {
24
+ return folder === "" ? filename : `${folder}/${filename}`;
25
+ }
26
+ function parentsOf(folder) {
27
+ const parts = folder.split("/");
28
+ return parts.slice(0, -1).map((_, i) => parts.slice(0, i + 1).join("/"));
29
+ }
30
+ function withSuffix(filename, n) {
31
+ const ext = extname(filename);
32
+ const stem = ext === "" ? filename : filename.slice(0, -ext.length);
33
+ return `${stem}-${n}${ext}`;
34
+ }
35
+ function typeAllowed(allowed, contentType) {
36
+ return allowed.some((pattern) => pattern === "*" || pattern === contentType || (pattern.endsWith("/*") && contentType.startsWith(pattern.slice(0, -1))));
37
+ }
38
+ const ORIGINS = new Set(["human", "ai", "mixed", "unknown"]);
39
+ export function parseProvenance(value) {
40
+ if (value === undefined || value === null || value === "")
41
+ return ok({ origin: "unknown" });
42
+ const raw = typeof value === "string" ? (value.startsWith("{") ? JSON.parse(value) : { origin: value }) : value;
43
+ if (typeof raw !== "object" || raw === null)
44
+ return err(failure("VALIDATION", "media/default: provenance must be an object or origin string"));
45
+ const p = raw;
46
+ if (typeof p.origin !== "string" || !ORIGINS.has(p.origin))
47
+ return err(failure("VALIDATION", "media/default: provenance.origin must be human, ai, mixed or unknown"));
48
+ const out = { origin: p.origin };
49
+ if (typeof p.tool === "string" && p.tool !== "")
50
+ out.tool = p.tool;
51
+ if (typeof p.model === "string" && p.model !== "")
52
+ out.model = p.model;
53
+ if (typeof p.at === "number" && Number.isFinite(p.at))
54
+ out.at = p.at;
55
+ return ok(out);
56
+ }
57
+ const MAX_TEXT_CHARS = 2000;
58
+ // every C0/C1 control character except tab and newline, which are legitimate in a description
59
+ // eslint-disable-next-line no-control-regex
60
+ const CONTROL_CHARS = /[\u0000-\u0008\u000B-\u001F\u007F]/;
61
+ export function checkPlainText(field, value) {
62
+ if (value.length > MAX_TEXT_CHARS || CONTROL_CHARS.test(value))
63
+ return err(failure("VALIDATION", `media/default: ${field} must be plain text (max ${MAX_TEXT_CHARS} chars)`));
64
+ return ok();
65
+ }
66
+ export function safeFolder(folder) {
67
+ const parts = folder.split("/").filter((p) => p !== "");
68
+ if (parts.some((p) => p === "." || p === ".." || !/^[A-Za-z0-9._-]+$/.test(p)))
69
+ return err(failure("VALIDATION", `media/default: invalid folder ${JSON.stringify(folder)}`));
70
+ return ok(parts.join("/"));
71
+ }
72
+ function exportTargetPath(base, folder, filename, used) {
73
+ const ext = extname(filename);
74
+ const stem = ext === "" ? filename : filename.slice(0, -ext.length);
75
+ let candidate = filename;
76
+ let attempt = 1;
77
+ while (used.has(resolvePath(base, folder, candidate))) {
78
+ attempt += 1;
79
+ candidate = `${stem}-${attempt}${ext}`;
80
+ }
81
+ const path = resolvePath(base, folder, candidate);
82
+ used.add(path);
83
+ return { path, conflict: attempt > 1 };
84
+ }
85
+ const META_SUFFIX = ".meta.json";
86
+ const MAX_NAME_BYTES = 200;
87
+ // image variants are owned by images/default, which this module cannot query; a media prefix that
88
+ // happens to cover their keys must not make reconcile() report or delete them.
89
+ const VARIANTS_PREFIX = "media-variants/";
90
+ function capName(name) {
91
+ if (Buffer.byteLength(name) <= MAX_NAME_BYTES)
92
+ return name;
93
+ const ext = extname(name);
94
+ const budget = MAX_NAME_BYTES - Buffer.byteLength(ext);
95
+ if (budget <= 0)
96
+ return Buffer.from(name).subarray(0, MAX_NAME_BYTES).toString("latin1");
97
+ const stem = ext === "" ? name : name.slice(0, -ext.length);
98
+ return Buffer.from(stem).subarray(0, budget).toString("latin1") + ext;
99
+ }
100
+ export function safeName(filename) {
101
+ const base = filename.split(/[\\/]/).pop() ?? "file";
102
+ const cleaned = base.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[.-]+/, "");
103
+ // capping before the .meta.json fix, so truncation can never re-create the suffix it removes
104
+ const named = capName(cleaned === "" ? "file" : cleaned);
105
+ // a filename ending in .meta.json would collide with a metadata sidecar key; neutralise it here, not just in migration
106
+ return named.replace(/\.meta\.json$/i, "-meta.json");
107
+ }
108
+ export async function createMediaDefault(config, blobs, db, logger, now = Date.now) {
109
+ const items = await db.ensureCollection(COLLECTION, {
110
+ filename: "string", folder: "string", contentType: "string", size: "number", key: "string", checksum: "string", status: "string", createdAt: "number", updatedAt: "number",
111
+ provenance: "json", width: "number", height: "number", alt: "json", title: "json", description: "json",
112
+ });
113
+ if (isErr(items))
114
+ throw new Error(`media/default: cannot prepare collection "${COLLECTION}": ${items.error.message}`);
115
+ const folders = await db.ensureCollection(FOLDERS, { path: "string", createdAt: "number" });
116
+ if (isErr(folders))
117
+ throw new Error(`media/default: cannot prepare collection "${FOLDERS}": ${folders.error.message}`);
118
+ // rows written before `status` existed are complete uploads; without this backfill they would
119
+ // drop out of every `status: "ready"` filter, because SQL comparisons against NULL never match.
120
+ const backfilled = await db.updateMany(COLLECTION, { status: { eq: null } }, { status: "ready" });
121
+ if (isErr(backfilled))
122
+ throw new Error(`media/default: cannot backfill the status of "${COLLECTION}": ${backfilled.error.message}`);
123
+ // media shares the blobstore with replication snapshots, the static site and redirects.json, so every
124
+ // media blob lives under config.prefix; the stored row key is the full blobstore key.
125
+ const blobKey = (folder, filename) => config.prefix + keyFor(folder, filename);
126
+ // a repeated move finds the source already gone: if the target is there, only the row update is left to do
127
+ const moveBlob = async (from, to) => {
128
+ const moved = await blobs.move(from, to);
129
+ if (!isErr(moved) || moved.error.code !== "NOT_FOUND")
130
+ return moved;
131
+ const target = await blobs.get(to);
132
+ if (isErr(target))
133
+ return target;
134
+ return target.value === null ? moved : ok();
135
+ };
136
+ const keyTaken = async (key, exceptId) => {
137
+ const row = await db.findOne(COLLECTION, { key });
138
+ if (isErr(row))
139
+ return row;
140
+ return ok(row.value !== null && row.value.id !== exceptId);
141
+ };
142
+ const assertFree = async (key, exceptId) => {
143
+ const taken = await keyTaken(key, exceptId);
144
+ if (isErr(taken))
145
+ return taken;
146
+ return taken.value ? err(failure("CONFLICT", `media/default: ${key} already exists`)) : ok();
147
+ };
148
+ // a row from an upload whose blob write failed owns nothing; it must not block its filename forever
149
+ const releaseFailed = async (key) => {
150
+ const row = await db.findOne(COLLECTION, { key });
151
+ if (isErr(row))
152
+ return row;
153
+ if (row.value === null || row.value.status !== "failed")
154
+ return ok();
155
+ return db.deleteOne(COLLECTION, row.value.id);
156
+ };
157
+ const ensureFolder = async (path) => {
158
+ if (path === "")
159
+ return ok();
160
+ const existing = await db.findOne(FOLDERS, { path });
161
+ if (isErr(existing))
162
+ return existing;
163
+ if (existing.value !== null)
164
+ return ok();
165
+ const created = await db.createOne(FOLDERS, { path, createdAt: now() });
166
+ return isErr(created) ? created : ok();
167
+ };
168
+ const eachItem = async (filter, fn) => {
169
+ let n = 0;
170
+ for (let offset = 0;; offset += 500) {
171
+ const page = await db.findMany(COLLECTION, filter, { sort: { createdAt: "asc", id: "asc" }, limit: 500, offset });
172
+ if (isErr(page))
173
+ return page;
174
+ for (const item of page.value.items) {
175
+ const applied = await fn(item);
176
+ if (isErr(applied))
177
+ return applied;
178
+ n += 1;
179
+ }
180
+ if (page.value.items.length < 500)
181
+ break;
182
+ }
183
+ return ok(n);
184
+ };
185
+ // Persistence supports only one operator per field, so a subtree can't be expressed as a single gte+lt range filter.
186
+ // Query `field >= "<path>/"` sorted ascending and stop once a row's value leaves the "<path>/" prefix — the sort
187
+ // keeps every matching row contiguous at the front, so this never needs LIKE (which mistreats "_" as a wildcard
188
+ // and is case-insensitive on SQLite).
189
+ const eachInSubtree = async (collection, field, path, fn) => {
190
+ const prefix = `${path}/`;
191
+ let n = 0;
192
+ for (let offset = 0;; offset += 500) {
193
+ const page = await db.findMany(collection, { [field]: { gte: prefix } }, { sort: { [field]: "asc" }, limit: 500, offset });
194
+ if (isErr(page))
195
+ return page;
196
+ for (const item of page.value.items) {
197
+ const value = item[field];
198
+ if (typeof value !== "string" || !value.startsWith(prefix))
199
+ return ok(n);
200
+ const applied = await fn(item);
201
+ if (isErr(applied))
202
+ return applied;
203
+ n += 1;
204
+ }
205
+ if (page.value.items.length < 500)
206
+ break;
207
+ }
208
+ return ok(n);
209
+ };
210
+ // Same one-operator-per-field constraint as eachInSubtree. "0" (0x30) is the first codepoint after "/" (0x2F)
211
+ // among the characters safeFolder allows, so a row at or past "<path>0" can never be "<path>" or a descendant.
212
+ const recursiveFolderItems = async (path) => {
213
+ const boundary = `${path}0`;
214
+ const descendant = `${path}/`;
215
+ const out = [];
216
+ for (let offset = 0;; offset += 500) {
217
+ const page = await db.findMany(COLLECTION, { folder: { gte: path }, status: "ready" }, { sort: { folder: "asc" }, limit: 500, offset });
218
+ if (isErr(page))
219
+ return page;
220
+ for (const item of page.value.items) {
221
+ if (item.folder >= boundary)
222
+ return ok(out);
223
+ if (item.folder === path || item.folder.startsWith(descendant))
224
+ out.push(item);
225
+ }
226
+ if (page.value.items.length < 500)
227
+ break;
228
+ }
229
+ return ok(out);
230
+ };
231
+ const subtreeItems = async (path) => {
232
+ const out = [];
233
+ const direct = await eachItem({ folder: path }, async (i) => {
234
+ out.push(i);
235
+ return ok();
236
+ });
237
+ if (isErr(direct))
238
+ return direct;
239
+ const nested = await eachInSubtree(COLLECTION, "folder", path, async (i) => {
240
+ out.push(i);
241
+ return ok();
242
+ });
243
+ if (isErr(nested))
244
+ return nested;
245
+ return ok(out);
246
+ };
247
+ const folderExists = async (path) => {
248
+ const row = await db.findOne(FOLDERS, { path });
249
+ if (isErr(row))
250
+ return row;
251
+ if (row.value !== null)
252
+ return ok(true);
253
+ const direct = await db.count(COLLECTION, { folder: path });
254
+ if (isErr(direct))
255
+ return direct;
256
+ if (direct.value > 0)
257
+ return ok(true);
258
+ let found = false;
259
+ const nestedItems = await eachInSubtree(COLLECTION, "folder", path, async () => {
260
+ found = true;
261
+ return ok();
262
+ });
263
+ if (isErr(nestedItems))
264
+ return nestedItems;
265
+ if (found)
266
+ return ok(true);
267
+ const nestedFolders = await eachInSubtree(FOLDERS, "path", path, async () => {
268
+ found = true;
269
+ return ok();
270
+ });
271
+ if (isErr(nestedFolders))
272
+ return nestedFolders;
273
+ return ok(found);
274
+ };
275
+ const fallbackLocale = config.defaultLocale ?? config.locales[0] ?? "";
276
+ const localeKey = (locale) => {
277
+ if (locale === undefined)
278
+ return ok(fallbackLocale);
279
+ if (config.locales.length > 0 && !config.locales.includes(locale))
280
+ return err(failure("VALIDATION", `media/default: unknown locale "${locale}"`));
281
+ return ok(locale);
282
+ };
283
+ const resolveWith = (row, key) => {
284
+ const item = { ...row, provenance: row.provenance ?? { origin: "unknown" }, updatedAt: row.updatedAt ?? row.createdAt, width: row.width ?? null, height: row.height ?? null, checksum: row.checksum ?? null, status: row.status ?? "ready" };
285
+ for (const field of TEXT_FIELDS) {
286
+ const texts = row[field];
287
+ item[field] = texts?.[key] ?? null;
288
+ }
289
+ return item;
290
+ };
291
+ return {
292
+ async upload(file, folder = "", provenance) {
293
+ if (!typeAllowed(config.allowedTypes, file.contentType) || typeAllowed(config.deniedTypes, file.contentType)) {
294
+ return err(failure("UNSUPPORTED", `media/default: type ${file.contentType} is not allowed`));
295
+ }
296
+ if (file.data.byteLength > config.maxBytes)
297
+ return err(failure("PAYLOAD_TOO_LARGE", `media/default: file exceeds ${config.maxBytes} bytes`));
298
+ const id = randomUUID();
299
+ const filename = safeName(file.filename);
300
+ const folderPath = safeFolder(folder);
301
+ if (isErr(folderPath))
302
+ return folderPath;
303
+ const key = blobKey(folderPath.value, filename);
304
+ const released = await releaseFailed(key);
305
+ if (isErr(released))
306
+ return released;
307
+ const free = await assertFree(key);
308
+ if (isErr(free))
309
+ return free;
310
+ const parsedProvenance = parseProvenance(provenance);
311
+ if (isErr(parsedProvenance))
312
+ return parsedProvenance;
313
+ const size = file.contentType.startsWith("image/") ? imageSize(file.data) : null;
314
+ const at = now();
315
+ const folderReady = await ensureFolder(folderPath.value);
316
+ if (isErr(folderReady))
317
+ return folderReady;
318
+ const created = await db.createOne(COLLECTION, {
319
+ id, filename, folder: folderPath.value, contentType: file.contentType, size: file.data.byteLength, key,
320
+ checksum: createHash("sha256").update(file.data).digest("hex"), status: "uploading", createdAt: at, updatedAt: at,
321
+ provenance: parsedProvenance.value, width: size?.width ?? null, height: size?.height ?? null, alt: {}, title: {}, description: {},
322
+ });
323
+ if (isErr(created))
324
+ return created;
325
+ const stored = await blobs.put(key, { data: file.data, contentType: file.contentType });
326
+ if (isErr(stored)) {
327
+ const marked = await db.updateOne(COLLECTION, id, { status: "failed", updatedAt: now() });
328
+ if (isErr(marked))
329
+ logger.error(`media/default: could not mark ${id} as failed`, { id, key, error: marked.error.message });
330
+ return stored;
331
+ }
332
+ const ready = await db.updateOne(COLLECTION, id, { status: "ready", updatedAt: now() });
333
+ if (isErr(ready))
334
+ return ready;
335
+ return ok(resolveWith(ready.value, fallbackLocale));
336
+ },
337
+ async get(id, locale) {
338
+ const row = await db.findOne(COLLECTION, { id });
339
+ if (isErr(row))
340
+ return row;
341
+ if (row.value === null)
342
+ return ok(null);
343
+ const key = localeKey(locale);
344
+ if (isErr(key))
345
+ return key;
346
+ const item = resolveWith(row.value, key.value);
347
+ return ok(item.status === "ready" ? item : null);
348
+ },
349
+ async list(options = {}) {
350
+ const { folder, recursive, q, sortBy, direction, locale, ...find } = options;
351
+ const key = localeKey(locale);
352
+ if (isErr(key))
353
+ return key;
354
+ const query = q?.trim() || undefined;
355
+ if (folder !== undefined && recursive === true) {
356
+ const prefix = safeFolder(folder);
357
+ if (isErr(prefix))
358
+ return prefix;
359
+ if (prefix.value !== "") {
360
+ const found = await recursiveFolderItems(prefix.value);
361
+ if (isErr(found))
362
+ return found;
363
+ const matched = query === undefined ? found.value : found.value.filter((i) => i.filename.includes(query));
364
+ const sorted = sortItems(matched, sortBy, direction);
365
+ const offset = find.offset ?? 0;
366
+ const sliced = find.limit === undefined ? sorted.slice(offset) : sorted.slice(offset, offset + find.limit);
367
+ return ok({ items: sliced.map((i) => resolveWith(i, key.value)), total: sorted.length });
368
+ }
369
+ }
370
+ const filter = { status: "ready" };
371
+ if (folder !== undefined) {
372
+ const clean = safeFolder(folder);
373
+ if (isErr(clean))
374
+ return clean;
375
+ filter.folder = clean.value;
376
+ }
377
+ const sort = { [sortBy ?? "createdAt"]: direction ?? (sortBy === undefined ? "desc" : "asc") };
378
+ if (query !== undefined) {
379
+ // LIKE is ASCII case-insensitive in SQL, so it can only narrow; the exact match is applied here.
380
+ filter.filename = { like: `%${query.replace(/[%_]/g, "")}%` };
381
+ const page = await db.findMany(COLLECTION, filter, { sort });
382
+ if (isErr(page))
383
+ return page;
384
+ const candidates = page.value.items.filter((i) => String(i.filename).includes(query));
385
+ const offset = find.offset ?? 0;
386
+ const sliced = find.limit === undefined ? candidates.slice(offset) : candidates.slice(offset, offset + find.limit);
387
+ return ok({ items: sliced.map((i) => resolveWith(i, key.value)), total: candidates.length });
388
+ }
389
+ const page = await db.findMany(COLLECTION, filter, { sort, ...find });
390
+ if (isErr(page))
391
+ return page;
392
+ return ok({ items: page.value.items.map((i) => resolveWith(i, key.value)), total: page.value.total });
393
+ },
394
+ async byIds(ids, locale) {
395
+ if (ids.length === 0)
396
+ return ok([]);
397
+ const key = localeKey(locale);
398
+ if (isErr(key))
399
+ return key;
400
+ const page = await db.findMany(COLLECTION, { id: { in: ids }, status: "ready" }, { limit: ids.length });
401
+ if (isErr(page))
402
+ return page;
403
+ const byId = new Map(page.value.items.map((i) => [i.id, resolveWith(i, key.value)]));
404
+ return ok(ids.map((id) => byId.get(id)).filter((i) => i !== undefined));
405
+ },
406
+ async folders() {
407
+ const counts = new Map();
408
+ for (let offset = 0;; offset += 500) {
409
+ const page = await db.findMany(COLLECTION, {}, { sort: { folder: "asc" }, limit: 500, offset });
410
+ if (isErr(page))
411
+ return page;
412
+ for (const item of page.value.items)
413
+ counts.set(item.folder, (counts.get(item.folder) ?? 0) + 1);
414
+ if (page.value.items.length < 500)
415
+ break;
416
+ }
417
+ for (let offset = 0;; offset += 500) {
418
+ const page = await db.findMany(FOLDERS, {}, { sort: { path: "asc" }, limit: 500, offset });
419
+ if (isErr(page))
420
+ return page;
421
+ for (const row of page.value.items)
422
+ if (!counts.has(row.path))
423
+ counts.set(row.path, 0);
424
+ if (page.value.items.length < 500)
425
+ break;
426
+ }
427
+ for (const folder of [...counts.keys()]) {
428
+ for (const parent of parentsOf(folder))
429
+ if (!counts.has(parent))
430
+ counts.set(parent, 0);
431
+ }
432
+ counts.delete("");
433
+ return ok([...counts].map(([folder, count]) => ({ folder, count })).sort((a, b) => a.folder.localeCompare(b.folder)));
434
+ },
435
+ async update(id, patch, locale) {
436
+ const found = await db.findOne(COLLECTION, { id });
437
+ if (isErr(found))
438
+ return found;
439
+ const existing = found.value;
440
+ if (existing === null)
441
+ return ok(null);
442
+ const key = localeKey(locale);
443
+ if (isErr(key))
444
+ return key;
445
+ const fields = { updatedAt: now() };
446
+ if (patch.filename !== undefined)
447
+ fields.filename = safeName(patch.filename);
448
+ if (patch.folder !== undefined) {
449
+ const clean = safeFolder(patch.folder);
450
+ if (isErr(clean))
451
+ return clean;
452
+ fields.folder = clean.value;
453
+ }
454
+ if (patch.provenance !== undefined) {
455
+ const parsed = parseProvenance(patch.provenance);
456
+ if (isErr(parsed))
457
+ return parsed;
458
+ fields.provenance = parsed.value;
459
+ }
460
+ for (const field of TEXT_FIELDS) {
461
+ const value = patch[field];
462
+ if (value === undefined)
463
+ continue;
464
+ if (value !== null && typeof value !== "string")
465
+ return err(failure("VALIDATION", `media/default: ${field} must be a string or null`));
466
+ if (typeof value === "string") {
467
+ const plain = checkPlainText(field, value);
468
+ if (isErr(plain))
469
+ return plain;
470
+ }
471
+ const texts = { ...(existing[field] ?? {}) };
472
+ if (value === null || value === "")
473
+ delete texts[key.value];
474
+ else
475
+ texts[key.value] = value;
476
+ fields[field] = texts;
477
+ }
478
+ // fields is fully built and validated (including text fields) before we touch the blob, so a rejected
479
+ // patch never leaves the blob moved with no matching row.
480
+ let moved = null;
481
+ if (patch.filename !== undefined || patch.folder !== undefined) {
482
+ const nextFolder = fields.folder ?? existing.folder;
483
+ const nextName = fields.filename ?? existing.filename;
484
+ const nextKey = blobKey(nextFolder, nextName);
485
+ if (nextKey !== existing.key) {
486
+ const free = await assertFree(nextKey, id);
487
+ if (isErr(free))
488
+ return free;
489
+ const relocated = await moveBlob(existing.key, nextKey);
490
+ if (isErr(relocated))
491
+ return relocated;
492
+ const folderReady = await ensureFolder(nextFolder);
493
+ if (isErr(folderReady))
494
+ return folderReady;
495
+ fields.key = nextKey;
496
+ moved = { from: existing.key, to: nextKey };
497
+ }
498
+ }
499
+ const row = await db.updateOne(COLLECTION, id, fields);
500
+ if (isErr(row)) {
501
+ if (moved !== null) {
502
+ const back = await moveBlob(moved.to, moved.from);
503
+ if (isErr(back))
504
+ logger.error(`media/default: could not move ${moved.to} back to ${moved.from} after a failed row update`, { id, error: back.error.message });
505
+ }
506
+ return row;
507
+ }
508
+ return ok(resolveWith(row.value, key.value));
509
+ },
510
+ async read(id) {
511
+ const row = await db.findOne(COLLECTION, { id });
512
+ if (isErr(row))
513
+ return row;
514
+ if (row.value === null)
515
+ return ok(null);
516
+ const item = resolveWith(row.value, fallbackLocale);
517
+ if (item.status !== "ready")
518
+ return ok(null);
519
+ const blob = await blobs.get(item.key);
520
+ if (isErr(blob))
521
+ return blob;
522
+ if (blob.value === null)
523
+ throw new Error(`media/default: blob ${item.key} for ${id} is missing`);
524
+ return ok({ item, data: blob.value.data });
525
+ },
526
+ async remove(id) {
527
+ const found = await db.findOne(COLLECTION, { id });
528
+ if (isErr(found))
529
+ return found;
530
+ if (found.value === null)
531
+ return ok();
532
+ const item = found.value;
533
+ const deleted = await db.deleteOne(COLLECTION, id);
534
+ if (isErr(deleted))
535
+ return deleted;
536
+ // the row is gone, so the blob is already unreachable; media.reconcile sweeps it up later
537
+ const dropped = await blobs.remove(item.key);
538
+ if (isErr(dropped))
539
+ logger.error(`media/default: could not delete blob ${item.key} of removed item ${id}`, { id, key: item.key, error: dropped.error.message });
540
+ return ok();
541
+ },
542
+ async createFolder(path) {
543
+ const clean = safeFolder(path);
544
+ if (isErr(clean))
545
+ return clean;
546
+ if (clean.value === "")
547
+ return err(failure("VALIDATION", "media/default: folder path must not be empty"));
548
+ const created = await ensureFolder(clean.value);
549
+ if (isErr(created))
550
+ return created;
551
+ const count = await db.count(COLLECTION, { folder: clean.value });
552
+ if (isErr(count))
553
+ return count;
554
+ return ok({ folder: clean.value, count: count.value });
555
+ },
556
+ async renameFolder(from, to) {
557
+ const source = safeFolder(from);
558
+ if (isErr(source))
559
+ return source;
560
+ const target = safeFolder(to);
561
+ if (isErr(target))
562
+ return target;
563
+ if (source.value === "" || target.value === "")
564
+ return err(failure("VALIDATION", "media/default: folder path must not be empty"));
565
+ const sourceExists = await folderExists(source.value);
566
+ if (isErr(sourceExists))
567
+ return sourceExists;
568
+ if (!sourceExists.value)
569
+ return ok(null);
570
+ if (target.value === source.value || target.value.startsWith(`${source.value}/`))
571
+ return err(failure("CONFLICT", `media/default: cannot move ${source.value} into itself`));
572
+ const targetExists = await folderExists(target.value);
573
+ if (isErr(targetExists))
574
+ return targetExists;
575
+ if (targetExists.value)
576
+ return err(failure("CONFLICT", `media/default: folder ${target.value} already exists`));
577
+ const found = await subtreeItems(source.value);
578
+ if (isErr(found))
579
+ return found;
580
+ const retarget = (folder) => target.value + folder.slice(source.value.length);
581
+ for (const item of found.value) {
582
+ const free = await assertFree(blobKey(retarget(item.folder), item.filename), item.id);
583
+ if (isErr(free))
584
+ return free;
585
+ }
586
+ for (const item of found.value) {
587
+ const folder = retarget(item.folder);
588
+ const key = blobKey(folder, item.filename);
589
+ const relocated = await moveBlob(item.key, key);
590
+ if (isErr(relocated))
591
+ return relocated;
592
+ const updated = await db.updateOne(COLLECTION, item.id, { folder, key, updatedAt: now() });
593
+ if (isErr(updated))
594
+ return updated;
595
+ }
596
+ const page = await db.findMany(FOLDERS, { path: source.value }, { limit: 1 });
597
+ if (isErr(page))
598
+ return page;
599
+ const rows = [...page.value.items];
600
+ const nested = await eachInSubtree(FOLDERS, "path", source.value, async (row) => {
601
+ rows.push(row);
602
+ return ok();
603
+ });
604
+ if (isErr(nested))
605
+ return nested;
606
+ for (const row of rows) {
607
+ const renamed = await db.updateOne(FOLDERS, row.id, { path: retarget(row.path) });
608
+ if (isErr(renamed))
609
+ return renamed;
610
+ }
611
+ const folderReady = await ensureFolder(target.value);
612
+ if (isErr(folderReady))
613
+ return folderReady;
614
+ return ok({ folder: target.value, moved: found.value.length });
615
+ },
616
+ async folderItems(path, recursive) {
617
+ const clean = safeFolder(path);
618
+ if (isErr(clean))
619
+ return clean;
620
+ if (clean.value === "")
621
+ return ok(null);
622
+ const exists = await folderExists(clean.value);
623
+ if (isErr(exists))
624
+ return exists;
625
+ if (!exists.value)
626
+ return ok(null);
627
+ const found = await subtreeItems(clean.value);
628
+ if (isErr(found))
629
+ return found;
630
+ let subfolders = 0;
631
+ const nested = await eachInSubtree(FOLDERS, "path", clean.value, async () => {
632
+ subfolders += 1;
633
+ return ok();
634
+ });
635
+ if (isErr(nested))
636
+ return nested;
637
+ if (!recursive && (found.value.length > 0 || subfolders > 0))
638
+ return err(failure("CONFLICT", `media/default: folder ${clean.value} is not empty`));
639
+ return ok({ path: clean.value, ids: found.value.map((i) => i.id) });
640
+ },
641
+ async removeFolder(path) {
642
+ const clean = safeFolder(path);
643
+ if (isErr(clean))
644
+ return clean;
645
+ if (clean.value === "")
646
+ return ok(null);
647
+ const exists = await folderExists(clean.value);
648
+ if (isErr(exists))
649
+ return exists;
650
+ if (!exists.value)
651
+ return ok(null);
652
+ const found = await subtreeItems(clean.value);
653
+ if (isErr(found))
654
+ return found;
655
+ for (const item of found.value) {
656
+ const dropped = await blobs.remove(item.key);
657
+ if (isErr(dropped))
658
+ return dropped;
659
+ const deleted = await db.deleteOne(COLLECTION, item.id);
660
+ if (isErr(deleted))
661
+ return deleted;
662
+ }
663
+ const subfolderIds = [];
664
+ const nested = await eachInSubtree(FOLDERS, "path", clean.value, async (row) => {
665
+ subfolderIds.push(row.id);
666
+ return ok();
667
+ });
668
+ if (isErr(nested))
669
+ return nested;
670
+ const removedFolder = await db.deleteMany(FOLDERS, { path: clean.value });
671
+ if (isErr(removedFolder))
672
+ return removedFolder;
673
+ if (subfolderIds.length > 0) {
674
+ const removedSubfolders = await db.deleteMany(FOLDERS, { id: { in: subfolderIds } });
675
+ if (isErr(removedSubfolders))
676
+ return removedSubfolders;
677
+ }
678
+ return ok({ ok: true, removed: found.value.length });
679
+ },
680
+ async migrateKeys() {
681
+ let moved = 0;
682
+ let renamed = 0;
683
+ let missing = 0;
684
+ let skipped = 0;
685
+ const migrate = async (item) => {
686
+ let filename = item.filename;
687
+ let key = blobKey(item.folder, filename);
688
+ const desired = key;
689
+ if (key === item.key)
690
+ return ok();
691
+ // a folder/filename that safeFolder/safeName would themselves change (or reject), or a key that
692
+ // would land on a metadata-sidecar suffix, is not safe to migrate automatically; leave it for
693
+ // manual cleanup instead of silently renaming or moving it.
694
+ const cleanFolder = safeFolder(item.folder);
695
+ if (isErr(cleanFolder) || cleanFolder.value !== item.folder || safeName(item.filename) !== item.filename || key.endsWith(META_SUFFIX)) {
696
+ skipped += 1;
697
+ logger.error(`media/default: item ${item.id} (${item.folder}/${item.filename}) cannot be safely migrated, skipped`, { id: item.id, key: item.key });
698
+ return ok();
699
+ }
700
+ const current = await blobs.get(item.key);
701
+ if (isErr(current))
702
+ return current;
703
+ if (current.value === null) {
704
+ // an interrupted earlier run may have moved the blob already; then only the row is behind
705
+ const already = await blobs.get(desired);
706
+ if (isErr(already))
707
+ return already;
708
+ if (already.value !== null) {
709
+ const folderReady = await ensureFolder(item.folder);
710
+ if (isErr(folderReady))
711
+ return folderReady;
712
+ const updated = await db.updateOne(COLLECTION, item.id, { key: desired, filename });
713
+ if (isErr(updated))
714
+ return updated;
715
+ moved += 1;
716
+ return ok();
717
+ }
718
+ missing += 1;
719
+ logger.error(`media/default: blob ${item.key} for ${item.id} is missing, key not migrated`, { id: item.id, key: item.key });
720
+ return ok();
721
+ }
722
+ for (let n = 2;; n += 1) {
723
+ const taken = await keyTaken(key, item.id);
724
+ if (isErr(taken))
725
+ return taken;
726
+ const present = await blobs.get(key);
727
+ if (isErr(present))
728
+ return present;
729
+ if (!taken.value && present.value === null)
730
+ break;
731
+ filename = withSuffix(item.filename, n);
732
+ key = blobKey(item.folder, filename);
733
+ }
734
+ if (filename !== item.filename)
735
+ renamed += 1;
736
+ const relocated = await moveBlob(item.key, key);
737
+ if (isErr(relocated))
738
+ return relocated;
739
+ const folderReady = await ensureFolder(item.folder);
740
+ if (isErr(folderReady))
741
+ return folderReady;
742
+ const updated = await db.updateOne(COLLECTION, item.id, { key, filename });
743
+ if (isErr(updated))
744
+ return updated;
745
+ moved += 1;
746
+ return ok();
747
+ };
748
+ const walked = await eachItem({}, async (item) => {
749
+ const migrated = await migrate(item);
750
+ if (isErr(migrated)) {
751
+ skipped += 1;
752
+ logger.error(`media/default: failed to migrate key for ${item.id}`, { id: item.id, key: item.key, error: migrated.error.message });
753
+ }
754
+ return ok();
755
+ });
756
+ if (isErr(walked))
757
+ return walked;
758
+ if (moved > 0 || missing > 0 || skipped > 0)
759
+ logger.info("media/default: migrated blob keys to <prefix><folder>/<filename>", { moved, renamed, missing, skipped });
760
+ return ok({ moved, renamed, missing, skipped });
761
+ },
762
+ async reconcile(options = {}) {
763
+ const stored = new Set();
764
+ const rowsWithoutBlob = [];
765
+ const listed = await blobs.list(config.prefix);
766
+ if (isErr(listed))
767
+ return listed;
768
+ const present = new Set(listed.value.map((info) => info.key).filter((key) => !key.startsWith(VARIANTS_PREFIX)));
769
+ const walked = await eachItem({}, async (item) => {
770
+ stored.add(item.key);
771
+ if (!present.has(item.key))
772
+ rowsWithoutBlob.push(item.key);
773
+ return ok();
774
+ });
775
+ if (isErr(walked))
776
+ return walked;
777
+ const blobsWithoutRow = [...present].filter((key) => !stored.has(key)).sort();
778
+ if (options.delete === true) {
779
+ for (const key of blobsWithoutRow) {
780
+ const dropped = await blobs.remove(key);
781
+ if (isErr(dropped))
782
+ return dropped;
783
+ }
784
+ }
785
+ if (blobsWithoutRow.length > 0 || rowsWithoutBlob.length > 0) {
786
+ logger.info("media/default: blobstore and media_items disagree", { blobsWithoutRow: blobsWithoutRow.length, rowsWithoutBlob: rowsWithoutBlob.length, deleted: options.delete === true ? blobsWithoutRow.length : 0 });
787
+ }
788
+ return ok({ blobsWithoutRow, rowsWithoutBlob: rowsWithoutBlob.sort() });
789
+ },
790
+ async exportTo(dir) {
791
+ const base = resolvePath(dir);
792
+ const used = new Set();
793
+ let written = 0;
794
+ let skipped = 0;
795
+ let missing = 0;
796
+ let conflicts = 0;
797
+ for (let offset = 0;; offset += 500) {
798
+ const page = await db.findMany(COLLECTION, {}, { sort: { createdAt: "asc", id: "asc" }, limit: 500, offset });
799
+ if (isErr(page))
800
+ return page;
801
+ for (const item of page.value.items) {
802
+ const { path: target, conflict } = exportTargetPath(base, item.folder, item.filename, used);
803
+ if (conflict)
804
+ conflicts += 1;
805
+ const rel = relative(base, target);
806
+ if (rel.startsWith(`..${sep}`) || rel === ".." || rel === "")
807
+ throw new Error(`media/default: export target ${target} escapes ${base}`);
808
+ const existing = await stat(target).catch((error) => {
809
+ if (error.code === "ENOENT")
810
+ return null;
811
+ throw error;
812
+ });
813
+ if (existing && existing.size === item.size && existing.mtimeMs >= item.updatedAt) {
814
+ skipped += 1;
815
+ continue;
816
+ }
817
+ const blob = await blobs.get(item.key);
818
+ if (isErr(blob))
819
+ return blob;
820
+ if (blob.value === null) {
821
+ missing += 1;
822
+ continue;
823
+ }
824
+ await mkdir(dirname(target), { recursive: true });
825
+ await writeFile(target, blob.value.data);
826
+ const updated = new Date(item.updatedAt);
827
+ await utimes(target, updated, updated);
828
+ written += 1;
829
+ }
830
+ if (page.value.items.length < 500)
831
+ break;
832
+ }
833
+ return ok({ written, skipped, missing, conflicts });
834
+ },
835
+ };
836
+ }