v0.8: auth integration, design persistence (#23), protected sweep (#24)

Server (spans the v0.6→v0.8 arc in one file):
- template-manager mounted with the productsStore catalog, PNG-only
  mockup multer, facet personalization config (§R10, FACET_KEY-gated,
  inert when unset), and the marketing-preview drainer (2s tick, one
  render per tick, .unref()).
- auth-manager bootstrap: DATABASE_URL gates the pg Pool, migrations,
  and mountAuth (magic-link customer + Google SSO admin + facet);
  absent DATABASE_URL logs a warning and the host boots without auth.
- goods-editor design persistence (#23): idempotent 001_designs.sql
  applied at boot, db handle passed to mountEditorApi so /api/designs
  routes mount; without Postgres the client falls back to
  cart-without-design_id.
- reference-protected TTL sweep (#24): before each pass, every
  /uploads/ basename referenced by template-manager JSON or persisted
  designs is collected and protected, and exports/designs (db-driven
  lifecycle, unrecoverable sources) is skipped entirely. Sweep kickoff
  moves below the bootstrap (pgPool TDZ). sweep-worker.mjs is the
  standalone process variant for production.

Client: main.jsx wraps the tree in AuthProvider (single /api/auth/me
fetch shared by useCustomer/useAdmin, degrades on 404) and mounts
AppRouter. eslint bumps server.js to ecmaVersion 2022 (top-level
await); .env.example documents the full v0.8 env surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
khalid@traclabs.com
2026-09-01 23:29:16 -05:00
parent dfa1f90a16
commit 840a5dbcb3
5 changed files with 614 additions and 27 deletions

113
scripts/sweep-worker.mjs Normal file
View File

@@ -0,0 +1,113 @@
#!/usr/bin/env node
/**
* Sweep worker. Runs runSweep on a tick — trash hard-delete, asset
* orphans, audit log rotation, marketing preview queue drain.
*
* Usage:
* node scripts/sweep-worker.mjs # one-shot
* node scripts/sweep-worker.mjs --loop # run on interval
*
* Production: run as a separate process (PM2, systemd, etc.) so the
* main API server doesn't lose responsiveness when sweep does heavy
* work (especially preview rendering, which can take seconds per
* template at scale).
*
* For v1 the sweep interval is 60 minutes when run with --loop.
* Adjust via the --interval=<minutes> flag.
*/
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import {
runSweep,
marketingPreviewRenderer,
createTemplateIndex,
} from 'template-manager/server';
import { PRODUCT_TYPES } from '../src/constants/products.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const STORAGE_PATH = join(__dirname, '..', 'templates');
const args = process.argv.slice(2);
const LOOP = args.includes('--loop');
const intervalArg = args.find((a) => a.startsWith('--interval='));
const INTERVAL_MIN = intervalArg ? parseInt(intervalArg.split('=')[1], 10) : 60;
async function buildRenderer() {
try {
const { renderDesignComposite } = await import('goods-editor/server');
const canvasMod = await import('canvas');
const sharpMod = await import('sharp').catch(() => null);
const factory = marketingPreviewRenderer({
editorRenderer: async (args) => renderDesignComposite({
...args,
canvas: canvasMod.default || canvasMod,
sharp: sharpMod ? (sharpMod.default || sharpMod) : null,
mockupsDir: join(__dirname, '..', 'mockups'),
uploadsDir: join(__dirname, '..', 'uploads'),
stickersDir: join(__dirname, '..', 'public', 'stickers'),
storageDir: STORAGE_PATH,
}),
});
// The factory returns a renderer expecting context. We don't have
// the in-memory marketingPreviewQueue here — that lives in the
// running API server's process. The worker reconciles via the
// metadata.autoStaleSince field: any template with that field
// set is a regen candidate.
return async (templateId) => {
// Skip if no audit log handle in this script — that's mounted
// server-side. The renderer logs failures via the audit log
// when one is provided; without one, errors silently retry on
// the next sweep.
const { createAuditLog } = await import('template-manager/server/lib/auditLog.js').catch(() => ({}));
const auditLog = createAuditLog ? createAuditLog(STORAGE_PATH) : null;
return factory({
templateId,
storagePath: STORAGE_PATH,
variantCatalog: PRODUCT_TYPES,
auditLog,
});
};
} catch (err) {
console.warn('⚠ Renderer not available; sweep will skip preview regen:', err.message);
return null;
}
}
async function runOnce(renderPreview) {
const templateIndex = createTemplateIndex(STORAGE_PATH);
const summary = await runSweep({
storagePath: STORAGE_PATH,
templateIndex,
renderPreview,
// marketingPreviewQueue is intentionally absent — the in-process
// queue lives in the API server. Worker process reconciles via
// metadata.autoStaleSince instead.
marketingPreviewQueue: null,
});
console.log('Sweep:', JSON.stringify(summary, null, 2));
}
async function main() {
const renderPreview = await buildRenderer();
if (!LOOP) {
await runOnce(renderPreview);
process.exit(0);
}
console.log(`▶ Sweep worker loop (interval: ${INTERVAL_MIN} min)`);
const tick = async () => {
try { await runOnce(renderPreview); }
catch (err) { console.error('Sweep tick failed:', err); }
};
await tick();
setInterval(tick, INTERVAL_MIN * 60 * 1000);
}
main().catch((err) => {
console.error('Sweep worker failed:', err);
process.exit(1);
});