VSEARCH / scripts /sync-catalogue.mjs
bep40's picture
Upload scripts/sync-catalogue.mjs
0aa45f0 verified
Raw History Blame Contribute Delete
5.41 kB
#!/usr/bin/env node
/**
* Mirror vai-avatar's live product sources into this Space, same-origin.
*
* Why a mirror and not a runtime cross-origin fetch:
* huggingface.co serves dataset files with
* `Access-Control-Allow-Origin: https://huggingface.co`, and `/resolve` then
* 302s to a CDN host that sets its own CORS policy. A page on
* *.static.hf.space is neither origin, so the browser hides the response
* body and the read fails with an opaque "Failed to fetch". Same-origin
* cannot be blocked, so the bytes are copied into the Space instead.
*
* Freshness therefore depends on this script running, not on a rebuild of any
* baked data: re-run it (or let the scheduled job run it) and the Space serves
* whatever vai-avatar has written at that moment.
*
* node scripts/sync-catalogue.mjs
*
* Writes public/catalogue/products.json and public/catalogue/promos.json.
* `public/` is what Vite copies into dist/, so both end up served with the app.
*/
import { mkdir, writeFile } from 'node:fs/promises';
import { realpathSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const OUT_DIR = resolve(ROOT, 'public/catalogue');
const REVISION = 'main';
const TIMEOUT_MS = 30_000;
/** Kept byte-identical to the sources declared in src/catalogue.js. */
export const SOURCES = {
'products.json': {
repo: 'bep40/grob-products-updated',
file: 'products_url_added.json',
},
'promos.json': {
repo: 'bep40/vaistudio-data',
file: 'promos.json',
},
};
function urlFor({ repo, file }) {
return `https://huggingface.co/datasets/${repo}/resolve/${REVISION}/${file}?cts=${Date.now()}`;
}
async function fetchSource(name, source) {
const url = urlFor(source);
process.stdout.write(`[sync] ${name} <- ${source.repo}/${source.file} ... `);
const response = await fetch(url, {
cache: 'no-store',
signal: AbortSignal.timeout(TIMEOUT_MS),
headers: process.env.HF_TOKEN ? { Authorization: `Bearer ${process.env.HF_TOKEN}` } : {},
});
if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`);
const text = await response.text();
// Validate before writing: a truncated or HTML error body would otherwise be
// served to the app as if it were a catalogue.
const parsed = JSON.parse(text);
const count = Array.isArray(parsed)
? `${parsed.length} products`
: `${Object.keys(parsed?.productEdits ?? {}).length} productEdits`;
process.stdout.write(`ok (${count})\n`);
return text;
}
/**
* Was this file the process entrypoint?
*
* Comparing basenames with `endsWith` looks equivalent and is not: any file whose
* name merely ENDS WITH this one (`my-sync-catalogue.mjs`) also matches, so
* loading such a file would run main() as a side effect of importing it. Both
* paths are resolved before comparing.
*/
function isDirectRun() {
const entry = process.argv[1];
if (!entry) return false;
try {
return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
async function main() {
await mkdir(OUT_DIR, { recursive: true });
let failures = 0;
for (const [name, source] of Object.entries(SOURCES)) {
try {
const text = await fetchSource(name, source);
await writeFile(resolve(OUT_DIR, name), text, 'utf8');
} catch (error) {
// The good file is still written, so one transient error does not throw
// away a successful fetch. Whether that mixture may be PUBLISHED is
// decided below.
failures++;
console.error(`[sync] FAILED ${name}: ${error.message}`);
}
}
/**
* ANY failure fails the run, not only a total failure.
*
* This matters from the second successful run onward, once public/catalogue is
* committed: a file that failed to fetch is then still on disk as its previous
* version, so requireRealFile() and recordCount() in refresh-catalogue.mjs both
* pass on that stale copy. The caller would then build, commit and print
* REFRESH_OK, and the Space would serve a fresh products.json beside an old
* promos.json -- so every Sửa and every Xóa from vai-avatar silently stopped
* applying, with no error anywhere.
*
* On the very first run the mirror is not committed yet, so the failed file is
* simply absent and the caller's guard already catches it. Failing here makes
* that behaviour unconditional instead of depending on which run it is.
*
* A stale overlay is not a degraded catalogue, it is a wrong one: catalogue.js
* merges the two files, so old edits resurrect old prices and old tombstones
* fight the new file. Aborting here is what makes the caller leave the
* previously committed mirror intact, which is the guarantee both scripts
* document.
*/
if (failures > 0) {
console.error(
`[sync] ${failures}/${Object.keys(SOURCES).length} source(s) failed — the mirror on disk `
+ 'is now MIXED (fresh file beside last-commit file). Refusing to report success: '
+ 'the caller must abort so the last-good mirror stays live.',
);
process.exitCode = 1;
return;
}
console.log(`[sync] wrote ${OUT_DIR}`);
}
if (isDirectRun()) {
main().catch(error => {
console.error(`[sync] ${error.stack || error}`);
process.exitCode = 1;
});
}