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;
}