feat: persist image captions and managed tags with fuzzy search

This commit is contained in:
smileBeda committed 2026-09-30 16:24:21 -03:00
1 parent 1385efec70
commit 082f715c39
4 files changed
+306 -83

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
import { HttpError } from './errors.js';
export const CAPTION_MAX_LENGTH = 2000;
export const TAG_MAX_LENGTH = 60;
export const TAG_LIMIT = 200;
export function normalizeTag(value) {
if (typeof value !== 'string') throw new HttpError(400, 'Tag must be a string.');
const name = value.normalize('NFC').trim().replace(/\s+/gu, ' ').toLowerCase();
if (!name || name.length > TAG_MAX_LENGTH || !/^[\p{L}\p{N}][\p{L}\p{N} _-]*$/u.test(name)) {
throw new HttpError(400, 'Tags must be 1–60 characters: letters, numbers, spaces, underscores, or hyphens.');
}
return name;
}
export function validateMetadata(input, availableTags) {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new HttpError(400, 'Expected a metadata object.');
const result = {};
if ('caption' in input) {
if (typeof input.caption !== 'string' || input.caption.length > CAPTION_MAX_LENGTH) {
throw new HttpError(400, `Caption must be a string of at most ${CAPTION_MAX_LENGTH} characters.`);
}
result.caption = input.caption.trim();
}
if ('tags' in input) {
if (!Array.isArray(input.tags) || input.tags.length > TAG_LIMIT) throw new HttpError(400, 'Tags must be an array of at most 200 managed tag names.');
result.tags = [...new Set(input.tags.map(normalizeTag))];
if (result.tags.some((tag) => !availableTags.includes(tag))) throw new HttpError(400, 'Choose tags from the managed tag catalog.');
}
return result;
}
export function normalizeAiMetadata(raw, availableTags) {
return {
caption: typeof raw?.caption === 'string' ? raw.caption.trim().slice(0, CAPTION_MAX_LENGTH) : '',
tags: Array.isArray(raw?.tags) ? [...new Set(raw.tags.filter((tag) => typeof tag === 'string')
.map((tag) => tag.trim().toLowerCase()).filter((tag) => availableTags.includes(tag)))] : []
};
}
+61
View File
@@ -0,0 +1,61 @@
import { HttpError } from './errors.js';
import { normalizeTag } from './metadata.js';
export function searchOptions(params) {
const q = (params.get('q') || '').trim();
if (q.length > 200) throw new HttpError(400, 'Search query must be at most 200 characters.');
const tags = params.getAll('tag');
if (tags.length > 20) throw new HttpError(400, 'Use at most 20 tag filters.');
return { q, tags: [...new Set(tags.map(normalizeTag))] };
}
export function rankMemes(entries, { q = '', tags = [], missingCaption = false } = {}) {
const query = fold(q);
const terms = [...new Set(words(query))];
return entries.filter((entry) => tags.every((tag) => entry.tags.includes(tag)) && (!missingCaption || !entry.caption))
.map((entry) => ({ entry, score: relevance(entry, query, terms) }))
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score || b.entry.createdAt.localeCompare(a.entry.createdAt) || a.entry.id.localeCompare(b.entry.id))
.map(({ entry }) => entry);
}
function relevance(entry, query, terms) {
if (!query) return 1;
if (!terms.length) return 0;
const text = fold([entry.caption, ...entry.tags].join(' '));
const candidates = [...new Set(words(text))];
let score = text.includes(query) ? 20 : 0;
for (const term of terms) {
let best = 0;
for (const candidate of candidates) {
if (candidate === term) best = Math.max(best, 10);
else if (candidate.startsWith(term)) best = Math.max(best, 6);
else if (term.length >= 4 && near(term, candidate, term.length >= 7 ? 2 : 1)) best = Math.max(best, 3);
}
if (!best) return 0; // Every query term must match; results remain useful for multiword searches.
score += best;
}
return score;
}
function fold(value) {
return value.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase();
}
function words(value) {
return value.match(/[\p{L}\p{N}]+/gu) || [];
}
function near(a, b, limit) {
if (Math.abs(a.length - b.length) > limit) return false;
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
for (let i = 1; i <= a.length; i += 1) {
const row = [i];
for (let j = 1; j <= b.length; j += 1) {
row[j] = Math.min(row[j - 1] + 1, previous[j] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
}
if (Math.min(...row) > limit) return false;
previous = row;
}
return previous[b.length] <= limit;
}
+112 -83
View File
@@ -1,15 +1,32 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { HttpError } from './errors.js';
import { normalizeTag, validateMetadata, TAG_LIMIT } from './metadata.js';
import { rankMemes } from './search.js';
export async function createStore({ dataDir }) { export async function createStore({ dataDir }) {
const root = path.resolve(dataDir); const root = path.resolve(dataDir);
const indexDir = path.join(root, 'index'); const indexDir = path.join(root, 'index');
const indexFile = path.join(indexDir, 'memes.jsonl'); const indexFile = path.join(indexDir, 'memes.jsonl');
const tagsFile = path.join(indexDir, 'tags.json');
await fs.mkdir(indexDir, { recursive: true }); await fs.mkdir(indexDir, { recursive: true });
const entries = await loadIndex(indexFile, root); const entries = await loadIndex(indexFile, root);
let tags = [];
try {
tags = [...new Set(JSON.parse(await fs.readFile(tagsFile, 'utf8')).map(normalizeTag))].sort();
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
for (const entry of entries) entry.tags = entry.tags.filter((tag) => tags.includes(tag));
const byId = new Map(entries.map((entry) => [entry.id, entry])); const byId = new Map(entries.map((entry) => [entry.id, entry]));
let writeChain = Promise.resolve(); let writeChain = Promise.resolve();
function mutate(operation) {
const result = writeChain.then(operation);
writeChain = result.catch(() => {});
return result;
}
const persist = () => persistAll(root, indexFile, entries);
return { return {
count(status) { count(status) {
@@ -21,27 +38,62 @@ export async function createStore({ dataDir }) {
absolutePath(storageKey) { absolutePath(storageKey) {
return path.join(root, storageKey); return path.join(root, storageKey);
}, },
list({ page, pageSize }) { listTags() {
const visible = entries.filter((entry) => entry.status === 'approved'); return [...tags];
const total = visible.length; },
const totalPages = Math.max(1, Math.ceil(total / pageSize)); addTag(name) {
const safePage = Math.min(page, totalPages); return mutate(async () => {
const start = (safePage - 1) * pageSize; const tag = normalizeTag(name);
return { if (tags.includes(tag)) throw new HttpError(409, 'Tag already exists.');
page: safePage, if (tags.length >= TAG_LIMIT) throw new HttpError(400, 'The tag catalog is limited to 200 tags.');
pageSize, tags = [...tags, tag].sort();
total, await atomicWrite(tagsFile, `${JSON.stringify(tags, null, 2)}\n`);
totalPages, return tag;
memes: visible.slice(start, start + pageSize).map(toPublicMeme) });
}; },
deleteTag(name) {
return mutate(async () => {
const tag = normalizeTag(name);
if (!tags.includes(tag)) throw new HttpError(404, 'Tag not found.');
tags = tags.filter((value) => value !== tag);
for (const entry of entries) {
if (entry.tags.includes(tag)) {
entry.tags = entry.tags.filter((value) => value !== tag);
entry.metadataVersion += 1;
}
}
await persist();
await atomicWrite(tagsFile, `${JSON.stringify(tags, null, 2)}\n`);
return tag;
});
},
list({ page, pageSize, q = '', tags: filters = [] }) {
return paginate(rankMemes(entries.filter((entry) => entry.status === 'approved'), { q, tags: filters }), page, pageSize);
},
listForAdmin({ page = 1, pageSize = 12, status = 'all', ...search } = {}) {
if (!['all', 'approved', 'pending'].includes(status)) throw new HttpError(400, 'Status must be all, approved, or pending.');
return paginate(rankMemes(entries.filter((entry) => status === 'all' || entry.status === status), search), page, pageSize);
}, },
listForReview({ status = 'pending' } = {}) { listForReview({ status = 'pending' } = {}) {
return { const memes = entries.filter((entry) => entry.status === status);
total: entries.filter((entry) => entry.status === status).length, return { total: memes.length, memes: memes.map(toPublicMeme) };
memes: entries.filter((entry) => entry.status === status).map(toPublicMeme)
};
}, },
async approve(ids) { updateMetadata(id, input, { expectedVersion } = {}) {
return mutate(async () => {
const record = byId.get(id);
if (!record) throw new HttpError(404, 'Meme not found.');
if (expectedVersion !== undefined && record.metadataVersion !== expectedVersion) {
throw new HttpError(409, 'Metadata changed during AI generation. Refresh and try again.');
}
const metadata = validateMetadata(input, tags);
if (!Object.keys(metadata).length) throw new HttpError(400, 'Supply caption and/or tags.');
Object.assign(record, metadata, { metadataVersion: record.metadataVersion + 1 });
await persist();
return toPublicMeme(record);
});
},
approve(ids) {
return mutate(async () => {
const approved = []; const approved = [];
for (const id of ids) { for (const id of ids) {
const record = byId.get(id); const record = byId.get(id);
@@ -51,13 +103,12 @@ export async function createStore({ dataDir }) {
record.moderationReason = `${record.moderationReason || 'Queued for review.'} Admin approved.`; record.moderationReason = `${record.moderationReason || 'Queued for review.'} Admin approved.`;
approved.push(toPublicMeme(record)); approved.push(toPublicMeme(record));
} }
if (approved.length > 0) { if (approved.length) await persist();
writeChain = writeChain.then(() => persistAll(root, indexFile, entries));
await writeChain;
}
return approved; return approved;
});
}, },
async delete(ids) { delete(ids) {
return mutate(async () => {
const deleted = []; const deleted = [];
for (const id of ids) { for (const id of ids) {
const index = entries.findIndex((entry) => entry.id === id); const index = entries.findIndex((entry) => entry.id === id);
@@ -68,76 +119,61 @@ export async function createStore({ dataDir }) {
await fs.rm(path.join(root, record.storageKey), { force: true }); await fs.rm(path.join(root, record.storageKey), { force: true });
await fs.rm(path.join(root, record.metaKey), { force: true }); await fs.rm(path.join(root, record.metaKey), { force: true });
} }
if (deleted.length > 0) { if (deleted.length) await persist();
writeChain = writeChain.then(() => persistAll(root, indexFile, entries));
await writeChain;
}
return deleted; return deleted;
});
}, },
async incrementMetric(id, metric) { incrementMetric(id, metric) {
return mutate(async () => {
const record = byId.get(id); const record = byId.get(id);
if (!record) return null; if (!record) return null;
if (metric !== 'viewCount' && metric !== 'downloadCount') return toPublicMeme(record); if (metric !== 'viewCount' && metric !== 'downloadCount') return toPublicMeme(record);
record[metric] = Number.isFinite(record[metric]) ? record[metric] + 1 : 1; record[metric] = Number.isFinite(record[metric]) ? record[metric] + 1 : 1;
writeChain = writeChain.then(() => persistRecord(root, indexFile, entries, record)); await persist();
await writeChain;
return toPublicMeme(record); return toPublicMeme(record);
});
}, },
async save({ save({ buffer, image, originalName, createdAt = new Date().toISOString(), demo = false,
buffer, status = 'approved', moderationScore = demo ? 90 : 0, moderationReason = '', originalMime = image.mime,
image, caption = '', tags: assignedTags = [] }) {
originalName, return mutate(async () => {
createdAt = new Date().toISOString(),
demo = false,
status = 'approved',
moderationScore = demo ? 90 : 0,
moderationReason = '',
originalMime = image.mime
}) {
const id = crypto.createHash('sha256').update(buffer).digest('hex'); const id = crypto.createHash('sha256').update(buffer).digest('hex');
const existing = byId.get(id); const existing = byId.get(id);
if (existing) return toPublicMeme(existing); if (existing) return toPublicMeme(existing);
const date = createdAt.slice(0, 10).replaceAll('-', '/'); const date = createdAt.slice(0, 10).replaceAll('-', '/');
const shard = `${id.slice(0, 2)}/${id.slice(2, 4)}`; const shard = `${id.slice(0, 2)}/${id.slice(2, 4)}`;
const storageKey = `memes/${date}/${shard}/${id}.${image.ext}`; const storageKey = `memes/${date}/${shard}/${id}.${image.ext}`;
const metaKey = `meta/${date}/${shard}/${id}.json`; const metaKey = `meta/${date}/${shard}/${id}.json`;
// Tags can be removed while an upload's AI request is running.
const metadata = validateMetadata({ caption, tags: assignedTags.filter((tag) => tags.includes(tag)) }, tags);
const record = { const record = {
id, id, createdAt, originalName, byteSize: image.byteSize, width: image.width, height: image.height,
createdAt, mime: image.mime, originalMime, ext: image.ext, status, moderationScore, moderationReason,
originalName, ...metadata, metadataVersion: 0, viewCount: 0, downloadCount: 0, storageKey, metaKey, demo
byteSize: image.byteSize,
width: image.width,
height: image.height,
mime: image.mime,
originalMime,
ext: image.ext,
status,
moderationScore,
moderationReason,
viewCount: 0,
downloadCount: 0,
storageKey,
metaKey,
demo
}; };
await fs.mkdir(path.dirname(path.join(root, storageKey)), { recursive: true }); await fs.mkdir(path.dirname(path.join(root, storageKey)), { recursive: true });
await fs.mkdir(path.dirname(path.join(root, metaKey)), { recursive: true }); await fs.mkdir(path.dirname(path.join(root, metaKey)), { recursive: true });
await fs.writeFile(path.join(root, storageKey), buffer, { flag: 'wx' }).catch((error) => { await fs.writeFile(path.join(root, storageKey), buffer, { flag: 'wx' }).catch((error) => {
if (error.code !== 'EEXIST') throw error; if (error.code !== 'EEXIST') throw error;
}); });
await persistRecord(root, indexFile, [...entries, record], record, { writeFileFlag: 'wx' });
entries.unshift(record); entries.unshift(record);
entries.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); entries.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
byId.set(id, record); byId.set(id, record);
await persist();
return toPublicMeme(record); return toPublicMeme(record);
});
} }
}; };
} }
function paginate(entries, page, pageSize) {
const total = entries.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const safePage = Math.min(page, totalPages);
const start = (safePage - 1) * pageSize;
return { page: safePage, pageSize, total, totalPages, memes: entries.slice(start, start + pageSize).map(toPublicMeme) };
}
async function loadIndex(indexFile, root) { async function loadIndex(indexFile, root) {
let content = ''; let content = '';
try { try {
@@ -145,7 +181,6 @@ async function loadIndex(indexFile, root) {
} catch (error) { } catch (error) {
if (error.code !== 'ENOENT') throw error; if (error.code !== 'ENOENT') throw error;
} }
const recordsById = new Map(); const recordsById = new Map();
for (const line of content.split('\n')) { for (const line of content.split('\n')) {
if (!line.trim()) continue; if (!line.trim()) continue;
@@ -157,6 +192,9 @@ async function loadIndex(indexFile, root) {
record.originalMime = record.originalMime || record.mime; record.originalMime = record.originalMime || record.mime;
record.viewCount = Number.isFinite(record.viewCount) ? record.viewCount : 0; record.viewCount = Number.isFinite(record.viewCount) ? record.viewCount : 0;
record.downloadCount = Number.isFinite(record.downloadCount) ? record.downloadCount : 0; record.downloadCount = Number.isFinite(record.downloadCount) ? record.downloadCount : 0;
record.caption = typeof record.caption === 'string' ? record.caption : '';
record.tags = Array.isArray(record.tags) ? record.tags : [];
record.metadataVersion = Number.isInteger(record.metadataVersion) ? record.metadataVersion : 0;
const mediaPath = path.join(root, record.storageKey); const mediaPath = path.join(root, record.storageKey);
const relative = path.relative(root, mediaPath); const relative = path.relative(root, mediaPath);
if (relative.startsWith('..') || path.isAbsolute(relative)) continue; if (relative.startsWith('..') || path.isAbsolute(relative)) continue;
@@ -166,32 +204,21 @@ async function loadIndex(indexFile, root) {
// Ignore corrupt index lines; individual metadata files remain on disk for recovery. // Ignore corrupt index lines; individual metadata files remain on disk for recovery.
} }
} }
const records = [...recordsById.values()]; return [...recordsById.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
records.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
return records;
} }
async function persistRecord(root, indexFile, entries, record, options = {}) { async function atomicWrite(file, content) {
await fs.writeFile(path.join(root, record.metaKey), `${JSON.stringify(record, null, 2)}\n`, { const temporary = `${file}.tmp`;
flag: options.writeFileFlag || 'w' await fs.writeFile(temporary, content);
}).catch((error) => { await fs.rename(temporary, file);
if (options.writeFileFlag === 'wx' && error.code === 'EEXIST') return;
throw error;
});
await persistAll(root, indexFile, entries);
} }
async function persistAll(root, indexFile, entries) { async function persistAll(root, indexFile, entries) {
await Promise.all(entries.map((entry) => fs.writeFile( await Promise.all(entries.map((entry) => atomicWrite(path.join(root, entry.metaKey), `${JSON.stringify(entry, null, 2)}\n`)));
path.join(root, entry.metaKey), await atomicWrite(indexFile, `${entries.map((entry) => JSON.stringify(entry)).join('\n')}\n`);
`${JSON.stringify(entry, null, 2)}\n`
).catch((error) => {
if (error.code !== 'ENOENT') throw error;
})));
await fs.writeFile(indexFile, `${entries.map((entry) => JSON.stringify(entry)).join('\n')}\n`);
} }
function toPublicMeme(record) { export function toPublicMeme(record) {
return { return {
id: record.id, id: record.id,
createdAt: record.createdAt, createdAt: record.createdAt,
@@ -203,6 +230,8 @@ function toPublicMeme(record) {
status: record.status, status: record.status,
moderationScore: record.moderationScore, moderationScore: record.moderationScore,
moderationReason: record.moderationReason, moderationReason: record.moderationReason,
caption: record.caption || '',
tags: [...(record.tags || [])],
viewCount: record.viewCount, viewCount: record.viewCount,
downloadCount: record.downloadCount, downloadCount: record.downloadCount,
originalName: record.originalName, originalName: record.originalName,
+94
View File
@@ -55,3 +55,97 @@ test('increments view and download counters', async () => {
assert.equal(listed.viewCount, 1); assert.equal(listed.viewCount, 1);
assert.equal(listed.downloadCount, 1); assert.equal(listed.downloadCount, 1);
}); });
async function metadataStore(t) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'meme-metadata-'));
t.after(() => fs.rm(dir, { recursive: true, force: true }));
const store = await createStore({ dataDir: dir });
let seed = 0;
async function save(options = {}) {
const color = ++seed;
const buffer = encodePng(8, 8, () => [color, 200, 50, 255]);
const image = validateImage(buffer, { maxBytes: 100_000, maxWidth: 6000, maxHeight: 6000, maxPixels: 20_000_000 });
return store.save({ buffer, image, originalName: 'test.png', ...options });
}
return { dir, store, save };
}
test('persists editable metadata and managed tags, removing assignments on tag deletion', async (t) => {
const { dir, store, save } = await metadataStore(t);
await store.addTag(' Cats ');
await store.addTag('work');
await assert.rejects(store.addTag('CATS'), { statusCode: 409 });
await assert.rejects(store.addTag('<script>'), { statusCode: 400 });
const meme = await save({ caption: 'An angry cat', tags: ['cats'], status: 'pending' });
await assert.rejects(store.updateMetadata(meme.id, { tags: ['invented'] }), { statusCode: 400 });
await assert.rejects(store.updateMetadata(meme.id, { caption: 'x'.repeat(2001) }), { statusCode: 400 });
await assert.rejects(store.updateMetadata(meme.id, { tags: null }), { statusCode: 400 });
await store.updateMetadata(meme.id, { caption: 'A cat sleeping at work', tags: ['WORK', 'cats', 'cats'] });
const reloaded = await createStore({ dataDir: dir });
assert.deepEqual(reloaded.listTags(), ['cats', 'work']);
assert.equal(reloaded.get(meme.id).caption, 'A cat sleeping at work');
assert.deepEqual(reloaded.get(meme.id).tags, ['work', 'cats']);
assert.equal(reloaded.get(meme.id).status, 'pending');
await reloaded.deleteTag('cats');
const finalStore = await createStore({ dataDir: dir });
assert.deepEqual(finalStore.get(meme.id).tags, ['work']);
assert.deepEqual(finalStore.listTags(), ['work']);
const diskRecord = JSON.parse(await fs.readFile(path.join(dir, finalStore.get(meme.id).metaKey), 'utf8'));
assert.deepEqual(diskRecord.tags, ['work']);
});
test('searches captions with relevance, typo tolerance, accents, topic intersections, and approved-only visibility', async (t) => {
const { store, save } = await metadataStore(t);
await store.addTag('cats');
await store.addTag('work');
const older = await save({ caption: 'An angry cat at the office', tags: ['cats', 'work'], createdAt: '2026-01-01T00:00:00Z' });
const newer = await save({ caption: 'An angry catfish sleeping', tags: [], createdAt: '2026-02-01T00:00:00Z' });
const accent = await save({ caption: 'A café worker', tags: ['work'] });
const pending = await save({ caption: 'An angry cat at the office', status: 'pending', tags: ['cats', 'work'] });
const list = (options = {}) => store.list({ page: 1, pageSize: 12, ...options });
assert.deepEqual(list({ q: 'ANGRY cat' }).memes.map((meme) => meme.id), [older.id, newer.id]);
assert.deepEqual(list({ q: 'angry catt' }).memes.map((meme) => meme.id), [older.id]);
assert.equal(list({ q: 'cafe' }).memes[0].id, accent.id);
assert.equal(list({ q: 'ang' }).memes[0].id, newer.id); // Equal relevance uses newest-first order.
assert.deepEqual(list({ tags: ['cats', 'work'] }).memes.map((meme) => meme.id), [older.id]);
assert.equal(list({ q: 'cat banana' }).total, 0);
assert.equal(list({ q: '!!!' }).total, 0);
assert.equal(list({ tags: ['unknown'] }).total, 0);
assert.equal(list({ q: older.id }).total, 0);
assert.ok(!list().memes.some((meme) => meme.id === pending.id));
assert.equal(list({ page: 99, pageSize: 1 }).page, 3);
const missing = await save();
assert.deepEqual(store.listForAdmin({ missingCaption: true }).memes.map((meme) => meme.id), [missing.id]);
assert.equal(store.listForAdmin({ status: 'all' }).total, 5);
assert.equal(list().memes.some((meme) => meme.id === newer.id), true);
});
test('prevents AI generation from overwriting concurrent edits and serializes writes', async (t) => {
const { dir, store, save } = await metadataStore(t);
const meme = await save({ caption: 'Original' });
const version = store.get(meme.id).metadataVersion;
await Promise.all([
store.updateMetadata(meme.id, { caption: 'Admin correction' }),
store.incrementMetric(meme.id, 'viewCount')
]);
await assert.rejects(store.updateMetadata(meme.id, { caption: 'AI result' }, { expectedVersion: version }), { statusCode: 409 });
const reloaded = await createStore({ dataDir: dir });
assert.equal(reloaded.get(meme.id).caption, 'Admin correction');
assert.equal(reloaded.get(meme.id).viewCount, 1);
assert.equal('metadataVersion' in reloaded.list({ page: 1, pageSize: 12 }).memes[0], false);
});
test('loads legacy images with empty metadata without changing their status', async (t) => {
const { dir, store, save } = await metadataStore(t);
const meme = await save();
const record = { ...store.get(meme.id) };
delete record.caption;
delete record.tags;
delete record.metadataVersion;
await fs.writeFile(path.join(dir, 'index', 'memes.jsonl'), `${JSON.stringify(record)}\n`);
const reloaded = await createStore({ dataDir: dir });
const result = reloaded.list({ page: 1, pageSize: 12 }).memes[0];
assert.equal(result.caption, '');
assert.deepEqual(result.tags, []);
assert.equal(result.status, 'approved');
});