← back to Dw Activation Calendar
scripts/prune-active.js
132 lines
'use strict';
/*
* prune-active.js — keep the cadence to STAGED (DRAFT) items ONLY.
*
* The persisted schedule (data/activation-schedule.json) is a frozen projection
* built nightly by generate-schedule.js from the DRAFT set. Between regens the
* hourly rotation activator flips ~500/day DRAFT→ACTIVE, and once the local
* shopify_products mirror re-syncs those rows to ACTIVE the snapshot goes stale:
* it still lists items that are already live. Steve's rule — the daily cadence
* must contain ONLY staged items — so this prunes any scheduled SKU whose CURRENT
* mirror status is anything other than DRAFT (ACTIVE / ARCHIVED / deleted).
*
* Idempotent, local-only, reversible: it only rewrites the derived JSON snapshot;
* it never writes dw_unified and never touches Shopify. A no-op when the snapshot
* is already clean. The nightly generate-schedule.js still fully rebuilds order +
* dates from scratch; this is the intra-day guard that keeps the view honest
* between those rebuilds. Meta counts are recomputed to stay faithful.
*
* Usage:
* node scripts/prune-active.js # prune in place, log the delta
* node scripts/prune-active.js --dry-run # report what would be pruned, no write
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const { Pool } = require('pg');
const DRY = process.argv.includes('--dry-run');
const FILE = path.join(__dirname, '..', 'data', 'activation-schedule.json');
// The rotation activator's per-day audit trail of exactly which ids it flipped
// live. Downstream of the activator (it only ever confirms "this system just
// activated X"), so it closes the mirror-resync lag WITHOUT introducing a second
// status oracle. DTD verdict C (2026-07-22, unanimous 5/5).
const ACTIVATOR_OUT = path.join(os.homedir(), 'Projects', 'dw-rotation-activator', 'out');
// Collect shopify_ids the activator recorded as action:'activated' in the last
// couple of days' audit files (covers the day-boundary window; older activations
// are long since mirror-synced). Read-only; a missing/partial file is ignored.
function justActivatedIds() {
const ids = new Set();
const day = (offset) => {
const d = new Date(); d.setDate(d.getDate() - offset);
return d.toISOString().slice(0, 10);
};
for (const stamp of [day(0), day(1)]) {
const f = path.join(ACTIVATOR_OUT, `rotation-activations-${stamp}.jsonl`);
let txt; try { txt = fs.readFileSync(f, 'utf8'); } catch { continue; }
for (const line of txt.split('\n')) {
if (!line) continue;
try { const r = JSON.parse(line); if (r.action === 'activated' && r.shopify_id) ids.add(String(r.shopify_id)); }
catch { /* skip a torn/partial line */ }
}
}
return ids;
}
const pool = process.env.DATABASE_URL
? new Pool({ connectionString: process.env.DATABASE_URL, max: 2 })
: new Pool({ host: process.env.PGHOST || '/tmp', database: process.env.PGDATABASE || 'dw_unified',
user: process.env.PGUSER || process.env.USER || 'stevestudio2', max: 2 });
function recomputeMeta(prevMeta, skus) {
const total = skus.length;
const tier0 = skus.filter(s => Number(s.mat_tier) === 0).length;
return {
...prevMeta,
total_items: total,
distinct_products: new Set(skus.map(s => s.shopify_id)).size,
tier0_textures: tier0,
tier1_rest: total - tier0,
blank_sku_items: skus.filter(s => !s.dw_sku).length,
end_date: total ? skus[total - 1].go_live_date : prevMeta.end_date,
days_to_clear: Math.ceil(total / (prevMeta.per_day || 500)),
coverage: `${total}/${total}`,
pruned_at: new Date().toISOString(),
};
}
async function main() {
let doc;
try { doc = JSON.parse(fs.readFileSync(FILE, 'utf8')); }
catch (e) { console.error('prune-active: cannot read schedule file:', e.message); process.exit(1); }
const skus = Array.isArray(doc.skus) ? doc.skus : [];
if (!skus.length) { console.log('prune-active: empty schedule, nothing to do.'); return; }
const ids = [...new Set(skus.map(s => String(s.shopify_id)).filter(Boolean))];
// Which of the scheduled ids are currently DRAFT (= still staged) in the mirror.
const c = await pool.connect();
let draft;
try {
const r = await c.query(
`SELECT sp.shopify_id::text AS id
FROM shopify_products sp
JOIN unnest($1::text[]) q(id) ON sp.shopify_id::text = q.id
WHERE sp.status = 'DRAFT'`, [ids]);
draft = new Set(r.rows.map(x => x.id));
} finally { c.release(); await pool.end(); }
// Zero-lag layer: even if the mirror hasn't re-synced yet, an id the activator
// just flipped (its own audit) is no longer staged — drop it now.
const flipped = justActivatedIds();
// Keep only ids that are still DRAFT in the mirror AND weren't just activated.
// Track zero-lag catches (still DRAFT in mirror but already in the activator audit)
// in a single pass instead of re-scanning skus twice.
let lagCaught = 0;
const kept = skus.filter(s => {
const id = String(s.shopify_id);
if (!draft.has(id)) return false;
if (flipped.has(id)) { lagCaught++; return false; }
return true;
});
const removed = skus.length - kept.length;
if (lagCaught) console.log(`prune-active: ${lagCaught} of those caught via activator audit before the mirror re-synced (zero-lag).`);
if (removed === 0) { console.log(`prune-active: clean — all ${skus.length} scheduled items are still DRAFT.`); return; }
console.log(`prune-active: ${removed} already-active/non-draft item(s) ` +
`${DRY ? 'WOULD BE pruned' : 'pruned'} — ${kept.length} staged items remain.`);
if (DRY) return;
const meta = recomputeMeta(doc.meta || {}, kept);
// atomic-ish write: temp then rename so a reader never sees a half file
const tmp = FILE + '.tmp';
fs.writeFileSync(tmp, JSON.stringify({ meta, skus: kept }, null, 0));
fs.renameSync(tmp, FILE);
console.log(`prune-active: wrote ${FILE} (total_items ${skus.length} -> ${kept.length}).`);
}
main().catch(e => { console.error('prune-active failed:', e); process.exit(1); });