All source files · Workbench
scripts/seed-sanity.mjs
import { readFile } from 'node:fs/promises';
const API_VERSION = '2026-10-02';
const allowedTypes = new Set(['competition', 'evidenceSource', 'sourceClaim', 'evidenceRevision', 'reviewDecision', 'projectNote']);
const allowedDecisions = new Set(['pending', 'approved', 'rejected']);
const isDate = (value) => typeof value === 'string' && !Number.isNaN(Date.parse(value));
function validateDocuments(documents) {
const ids = new Set();
const errors = [];
for (const [index, document] of documents.entries()) {
const location = `Line ${index + 1}`;
if (!document || typeof document !== 'object' || Array.isArray(document)) { errors.push(`${location}: expected a document.`); continue; }
if (!/^[a-zA-Z0-9_-]+$/.test(document._id || '')) errors.push(`${location}: invalid or missing _id.`);
if (ids.has(document._id)) errors.push(`${location}: duplicate _id.`);
ids.add(document._id);
if (!allowedTypes.has(document._type)) errors.push(`${location}: unsupported _type.`);
if (document._type === 'competition') {
if (!document.title || !document.slug?.current || !isDate(document.deadlineUtc) || !isDate(document.startUtc)) errors.push(`${location}: competition lacks title/slug/start/deadline.`);
if (Date.parse(document.deadlineUtc) <= Date.parse(document.startUtc)) errors.push(`${location}: deadline must follow start.`);
if (document.buildHours !== undefined && (!(document.buildHours >= 0) || !document.effortEstimateNote)) errors.push(`${location}: build hours need a nonnegative value and estimate label.`);
if (document.humanMinutes !== undefined && !(document.humanMinutes >= 0)) errors.push(`${location}: human minutes must be nonnegative.`);
}
if (document._type === 'evidenceSource' && !document.isSimulated && !/^https:\/\//.test(document.url || '')) errors.push(`${location}: real sources require https URLs.`);
if (document._type === 'reviewDecision') {
if (!allowedDecisions.has(document.decision) || !document.decisionNote || !document.reviewerLabel) errors.push(`${location}: invalid review record.`);
if (document.decision !== 'pending' && !isDate(document.decidedAt)) errors.push(`${location}: final decision needs timestamp.`);
}
if (document.isSimulated) {
const label = `${document.title || ''} ${document.summary || ''} ${document.description || ''} ${document.note || ''} ${document.decisionNote || ''} ${document.body || ''}`;
if (!/simulat/i.test(label)) errors.push(`${location}: simulated data lacks an explicit label.`);
}
}
function inspectReferences(value, owner) {
if (!value || typeof value !== 'object') return;
if (value._type === 'reference' && !ids.has(value._ref)) errors.push(`${owner}: unknown reference ${value._ref}.`);
for (const child of Object.values(value)) {
if (Array.isArray(child)) child.forEach((entry) => inspectReferences(entry, owner));
else if (child && typeof child === 'object') inspectReferences(child, owner);
}
}
documents.forEach((document) => inspectReferences(document, document._id));
if (errors.length) throw new Error(errors.join('\n'));
}
try {
const argumentsSet = new Set(process.argv.slice(2));
if ([...argumentsSet].some((argument) => !['--apply', '--replace', '--dry-run'].includes(argument))) {
throw new Error('Usage: node scripts/seed-sanity.mjs [--apply] [--replace] [--dry-run]');
}
const text = await readFile(new URL('../data/seed.ndjson', import.meta.url), 'utf8');
const documents = text.split(/\r?\n/).filter((line) => line.trim()).map((line) => JSON.parse(line));
validateDocuments(documents);
const counts = documents.reduce((result, document) => { result[document._type] = (result[document._type] || 0) + 1; return result; }, {});
console.log(`Validated ${documents.length} public documents: ${JSON.stringify(counts)}`);
if (!argumentsSet.has('--apply')) {
console.log('Validation only. No account, network request, or dataset mutation was made. Use --apply only for your intended public Sanity dataset.');
} else {
const projectId = process.env.SANITY_PROJECT_ID || process.env.PUBLIC_SANITY_PROJECT_ID || '';
const dataset = process.env.SANITY_DATASET || process.env.PUBLIC_SANITY_DATASET || 'production';
const token = process.env.SANITY_API_WRITE_TOKEN || '';
if (!/^[a-z0-9]{1,32}$/.test(projectId) || projectId === 'your-project-id' || !/^[a-z0-9_-]{1,64}$/.test(dataset)) throw new Error('Set a valid SANITY_PROJECT_ID and SANITY_DATASET.');
if (!token) throw new Error('SANITY_API_WRITE_TOKEN is required in the local process environment. Never prefix it with PUBLIC_ or commit it.');
// Verify the target is public before writing the public-only seed.
const publicUrl = new URL(`https://${projectId}.api.sanity.io/v${API_VERSION}/data/query/${dataset}`);
publicUrl.searchParams.set('query', 'count(*)');
publicUrl.searchParams.set('perspective', 'published');
const publicCheck = await fetch(publicUrl, { signal: AbortSignal.timeout(10000) });
if (!publicCheck.ok) throw new Error(`Target dataset is not readable publicly (${publicCheck.status}); no seed mutation made.`);
const payload = await publicCheck.json();
if (typeof payload.result !== 'number') throw new Error('Cannot verify public dataset; no seed mutation made.');
const operation = argumentsSet.has('--replace') ? 'createOrReplace' : 'createIfNotExists';
const url = new URL(`https://${projectId}.api.sanity.io/v${API_VERSION}/data/mutate/${dataset}`);
url.searchParams.set('returnIds', 'true');
url.searchParams.set('visibility', 'sync');
url.searchParams.set('autoGenerateArrayKeys', 'true');
if (argumentsSet.has('--dry-run')) url.searchParams.set('dryRun', 'true');
const response = await fetch(url, {
method: 'POST', signal: AbortSignal.timeout(20000),
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ mutations: documents.map((document) => ({ [operation]: document })) }),
});
if (!response.ok) throw new Error(`Sanity seed failed (${response.status}); remote body omitted.`);
const result = await response.json();
if (result.error || !result.transactionId) throw new Error('Sanity did not confirm the seed transaction.');
console.log(`${argumentsSet.has('--dry-run') ? 'Remote dry run confirmed' : 'Seed transaction confirmed'} for ${projectId}/${dataset}; mode ${operation}.`);
}
} catch (error) {
console.error(error instanceof Error ? error.message : 'Seed failed.');
process.exitCode = 1;
}