← back to Marketing Command Center
modules/engine/store.js
83 lines
// Engine queue store — persistence for data/engine-queue.json (an array of items).
// Atomic writes (write tmp → fs.renameSync) so a crash mid-write never leaves a
// half-written / corrupt queue (mirrors server.js writeNotes()). load() returns []
// on missing/corrupt so a bad file never crashes the module.
//
// QUEUE ITEM SHAPE (later agents — candidates.js / generate.js / scheduler.js —
// MUST conform to this exactly):
// {
// id: 'eng-<yyyymmdd>-<dwSkuOrShopifyId>-<channel>', // stable, dedupe key
// date: 'YYYY-MM-DD', // the suggestion's local calendar day
// createdAt: ISO,
// product: {
// shopifyId, dwSku, handle, title, vendor, productType, imageUrl, productUrl
// },
// channel, // instagram|facebook|bluesky|youtube|tiktok|threads
// caption,
// hashtags: [],
// mediaUrl,
// videoUrl: null,
// suggestedSlot: 'HH:MM', // from config.slots[channel]
// scheduledAt: null|ISO, // set on approve (full ISO w/ offset)
// privacy: null, // youtube: 'public'|'private'|'unlisted'
// status: 'suggested'|'approved'|'posting'|'posted'|'failed'|'skipped'|'gated',
// gateReason: null, // why a 'gated' item is held (TikTok/Threads review)
// source: { llm, cost: 0 }, // provenance from the LLM that wrote the caption
// attempts: 0,
// lastError: null,
// outboxId: null, // channels-outbox id once posted
// postedAt: null,
// detail: null // adapter result on post
// }
const fs = require('fs');
const path = require('path');
const QUEUE_FILE = path.join(__dirname, '..', '..', 'data', 'engine-queue.json');
// load() — return the queue array, or [] on missing/corrupt (never throws).
function load() {
try {
const v = JSON.parse(fs.readFileSync(QUEUE_FILE, 'utf8'));
return Array.isArray(v) ? v : [];
} catch { return []; }
}
// save(items) — atomic write (tmp → rename) of the whole queue array.
function save(items) {
try { fs.mkdirSync(path.dirname(QUEUE_FILE), { recursive: true }); } catch {}
const tmp = QUEUE_FILE + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(Array.isArray(items) ? items : [], null, 2));
fs.renameSync(tmp, QUEUE_FILE);
}
// get(id) — one item by id, or null.
function get(id) {
return load().find(it => it.id === String(id)) || null;
}
// upsert(item) — add or replace the item with the same id, persist, return it.
function upsert(item) {
const items = load();
const i = items.findIndex(x => x.id === item.id);
if (i >= 0) items[i] = { ...items[i], ...item };
else items.push(item);
save(items);
return item;
}
// transition(id, fromStatus, toStatus, patch={}) — the compare-and-set that
// guards against double-posting. Re-reads the file FRESH, finds the item, asserts
// its current status === fromStatus (returns null if not — the guard), applies
// { status: toStatus, ...patch }, persists, and returns the updated item.
function transition(id, fromStatus, toStatus, patch = {}) {
const items = load();
const i = items.findIndex(x => x.id === String(id));
if (i < 0) return null;
if (items[i].status !== fromStatus) return null; // compare-and-set failed
items[i] = { ...items[i], ...patch, status: toStatus };
save(items);
return items[i];
}
module.exports = { load, save, get, upsert, transition };