From 0c41b879b34c0985f42482ea6f42434c40e080d8 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 30 Jun 2026 10:29:37 -0400 Subject: [PATCH] searchable codemod: add republish tool republish.mjs republishes each published realm copy from its source realm so the published snapshot picks up the source's searchable annotations, then reads a known module back from the published realm to assert the annotation landed. The env argument is checked against a staging|prod allow-list before it is interpolated into the bash command that sources the seed file. The run exits nonzero on a real failure or a missing-searchable assertion, while a realm the server refuses to publish ("not publishable") is recorded as an expected skip and does not fail it. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0182qehwDYbwBmyYcAhMoij1 --- .../scripts/codemod/searchable/republish.mjs | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 packages/realm-server/scripts/codemod/searchable/republish.mjs diff --git a/packages/realm-server/scripts/codemod/searchable/republish.mjs b/packages/realm-server/scripts/codemod/searchable/republish.mjs new file mode 100644 index 0000000000..b599d0e5fd --- /dev/null +++ b/packages/realm-server/scripts/codemod/searchable/republish.mjs @@ -0,0 +1,103 @@ +// Republish each (source -> published) pair so the published snapshot picks up +// the searchable annotations now in its source realm, then ASSERT the annotation +// landed: `realm publish` waits for the published realm to be ready (fully +// indexed), after which we read a known-changed module back from the published +// realm and confirm it carries `searchable`. +// +// node republish.mjs [--limit N] +// You do not need to source the seed before running: each boxel-cli call sources +// ~/.boxel-secrets/.env itself, so the seed never enters this process. +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync, appendFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +let [pairsPath, env, out] = process.argv.slice(2); +let limitIdx = process.argv.indexOf('--limit'); +let limit = limitIdx > -1 ? Number(process.argv[limitIdx + 1]) : Infinity; +if (env !== 'staging' && env !== 'prod') { + // env is interpolated into the bash command that sources the seed file, so it + // must never carry arbitrary text. + throw new Error( + `env must be 'staging' or 'prod' (got ${JSON.stringify(env)})`, + ); +} +const HERE = dirname(fileURLToPath(import.meta.url)); +// …/scripts/codemod/searchable -> repo root +const REPO = join(HERE, '..', '..', '..', '..', '..'); +let BOXEL = join(REPO, 'packages', 'boxel-cli', 'bin', 'boxel.js'); + +function boxel(args, timeout = 660000) { + let q = args.map((x) => `'${x.replace(/'/g, `'\\''`)}'`).join(' '); + let cmd = `set -a; . "$HOME/.boxel-secrets/${env}.env"; set +a; exec node ${JSON.stringify(BOXEL)} ${q}`; + return execFileSync('bash', ['-c', cmd], { + encoding: 'utf8', + timeout, + maxBuffer: 64 * 1024 * 1024, + }); +} + +let pairs = JSON.parse(readFileSync(pairsPath, 'utf8')).slice(0, limit); +writeFileSync(out, ''); +let log = (o) => appendFileSync(out, JSON.stringify(o) + '\n'); +let ok = 0, + fail = 0, + skipped = 0, + assertFail = 0; +process.stderr.write(`Republishing ${pairs.length} pair(s) on ${env}\n`); + +for (let [i, p] of pairs.entries()) { + try { + // Waits for the published realm to be ready (fully indexed) by default. + boxel([ + 'realm', + 'publish', + p.source, + p.published, + '--realm-secret-seed', + '--force', + '--timeout', + '600000', + ]); + // Assert: the annotation is present in the published realm's source. + let r = JSON.parse( + boxel(['file', 'read', p.sampleModule, '--realm', p.published, '--json']), + ); + let present = Boolean(r.ok && (r.content || '').includes('searchable')); + if (!present) assertFail++; + log({ + published: p.published, + source: p.source, + sampleModule: p.sampleModule, + searchablePresent: present, + }); + process.stderr.write( + ` ${present ? '✓' : '⚠'} ${p.published} (searchable in ${p.sampleModule}: ${present})\n`, + ); + ok++; + } catch (e) { + let msg = String(e.message); + // A realm the server refuses to publish ("not publishable") is an expected + // skip, not a rollout failure — count it separately so it never trips the + // nonzero exit below. + let notPublishable = /not publishable/.test(msg); + if (notPublishable) skipped++; + else fail++; + log({ + published: p.published, + source: p.source, + ...(notPublishable ? { skipped: true } : {}), + error: msg.split('\n').slice(0, 2).join(' | '), + }); + process.stderr.write( + ` ${notPublishable ? '⊘ skip (not publishable):' : '✗'} ${p.published} — ${msg.split('\n')[0]}\n`, + ); + } + process.stderr.write(` …${i + 1}/${pairs.length}\n`); +} +process.stderr.write( + `\nDone. ${ok} republished, ${assertFail} missing-searchable, ${skipped} skipped (not publishable), ${fail} failed.\n`, +); +// Surface real failures to callers/CI; expected "not publishable" skips do not +// fail the run. +process.exit(fail > 0 || assertFail > 0 ? 1 : 0);