From d09826f409092c8b03579fce8de49326d290b818 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 22 Oct 2026 08:40:53 -0400 Subject: [PATCH 1/3] Index public-realm deploys and system reindexes without realm events Asynchronous indexing that a user's own session didn't ask for must not reset that session's loader. A deploy of a public realm's content, the post-deploy boxel-ui reindex, Grafana reindexes, the nightly OpenRouter sync and the boot reindex of bootstrap realms now index silently; open tabs pick the result up on their next load. - from-scratch args carry `realmEvents: false` (OR-merged on coalesce; an announcing pass never rides a silent in-flight one), which turns off the render job's prerender_html announcement and follow-up incremental events - the full-reindex job and enqueueReindexRealmJob thread it through - startReindex skips its invalidation and `full` broadcasts when silent, and a .boxelignore write sent with X-Boxel-Realm-Events: none starts a silent reindex - /_grafana-reindex, /_grafana-full-reindex and /_post-deployment enqueue silently - cardService.executeAtomicOperations takes `realmEvents: false`; the OpenRouter sync tool passes it - `boxel file write --no-realm-events` Co-Authored-By: Claude Opus 5.5 --- packages/boxel-cli/src/commands/file/write.ts | 29 +++- .../tests/integration/file-write.test.ts | 55 +++++- packages/host/app/services/card-service.ts | 13 +- .../host/app/tools/sync-openrouter-models.ts | 4 + .../handlers/handle-full-reindex.ts | 4 + .../handlers/handle-post-deployment.ts | 4 + .../realm-server/handlers/handle-reindex.ts | 4 + .../tests/atomic-endpoints-test.ts | 48 ++++++ .../realm-server/tests/full-reindex-test.ts | 19 +++ packages/realm-server/tests/queue-test.ts | 158 ++++++++++++++++++ .../maintenance-endpoints-test.ts | 16 +- packages/runtime-common/jobs/reindex-realm.ts | 6 + .../runtime-common/realm-index-updater.ts | 29 +++- packages/runtime-common/realm.ts | 59 ++++--- packages/runtime-common/tasks/full-reindex.ts | 24 ++- packages/runtime-common/tasks/indexer.ts | 63 +++++-- 16 files changed, 477 insertions(+), 58 deletions(-) diff --git a/packages/boxel-cli/src/commands/file/write.ts b/packages/boxel-cli/src/commands/file/write.ts index cd43ab300d8..cb722baee8b 100644 --- a/packages/boxel-cli/src/commands/file/write.ts +++ b/packages/boxel-cli/src/commands/file/write.ts @@ -22,6 +22,12 @@ export interface WriteCommandOptions { realmSecretSeed?: string; /** @internal Test hook: supply an already-constructed authenticator. */ authenticator?: RealmAuthenticator; + /** + * When false, the write carries `X-Boxel-Realm-Events: none`, so the realm + * indexes it without announcing it to its subscribers. For deploys, whose + * changes open tabs pick up on their next load. On by default. + */ + realmEvents?: boolean; } interface WriteCliOptions { @@ -29,6 +35,7 @@ interface WriteCliOptions { file?: string; json?: boolean; realmSecretSeed?: boolean; + realmEvents?: boolean; } /** @@ -99,12 +106,17 @@ export async function write( try { let response = await authenticator.authedRealmFetch(url, { method: 'POST', - headers: isBinary - ? { 'Content-Type': SupportedMimeType.OctetStream } - : { - Accept: SupportedMimeType.CardSource, - 'Content-Type': SupportedMimeType.CardSource, - }, + headers: { + ...(isBinary + ? { 'Content-Type': SupportedMimeType.OctetStream } + : { + Accept: SupportedMimeType.CardSource, + 'Content-Type': SupportedMimeType.CardSource, + }), + ...(options?.realmEvents === false + ? { 'X-Boxel-Realm-Events': 'none' } + : {}), + }, // Both branches of `body: string | Uint8Array` are valid BodyInit // values, but TS narrows them as a union that doesn't unify against // the fetch signature without a hint. @@ -158,6 +170,10 @@ export function registerWriteCommand(parent: Command): void { '--realm-secret-seed', 'Administrative auth: prompt for a realm secret seed and mint a JWT locally instead of using a Matrix profile (env: BOXEL_REALM_SECRET_SEED)', ) + .option( + '--no-realm-events', + 'Index the write without announcing it to open tabs, which pick it up on their next load (for deploys)', + ) .option('--json', 'Output raw JSON response') .action(async (filePath: string, opts: WriteCliOptions) => { // Resolve the seed before consuming stdin: when content arrives on stdin @@ -221,6 +237,7 @@ export function registerWriteCommand(parent: Command): void { try { result = await write(opts.realm, filePath, content, { realmSecretSeed, + realmEvents: opts.realmEvents, }); } catch (err) { stderr( diff --git a/packages/boxel-cli/tests/integration/file-write.test.ts b/packages/boxel-cli/tests/integration/file-write.test.ts index aa654586989..6bf0d657eec 100644 --- a/packages/boxel-cli/tests/integration/file-write.test.ts +++ b/packages/boxel-cli/tests/integration/file-write.test.ts @@ -1,5 +1,5 @@ import '../helpers/setup-realm-server.ts'; -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -12,6 +12,7 @@ import { createTestRealmViaCli, } from '../helpers/integration.ts'; import { runBoxel } from '../helpers/run-boxel.ts'; +import { write } from '../../src/commands/file/write.ts'; import { TINY_PNG_BYTES, TINY_PDF_BYTES, @@ -218,4 +219,56 @@ describe('file write (integration)', () => { fs.rmSync(emptyHome, { recursive: true, force: true }); } }); + it('marks a write sent with realm events off, and leaves a plain write unmarked', async () => { + // In-process so the request the write sends can be inspected. + let profileManager = reloadProfile(home); + let realFetch = profileManager.authedRealmFetch.bind(profileManager); + let markers: (string | null)[] = []; + let fetchSpy = vi + .spyOn(profileManager, 'authedRealmFetch') + .mockImplementation(async (input, init) => { + if (init?.method === 'POST') { + markers.push(new Headers(init?.headers).get('X-Boxel-Realm-Events')); + } + return realFetch(input, init); + }); + + try { + let plain = await write(realmUrl, 'plain.gts', 'export const p = 1;', { + profileManager, + }); + expect(plain.ok, plain.error).toBe(true); + let silent = await write(realmUrl, 'silent.gts', 'export const s = 1;', { + profileManager, + realmEvents: false, + }); + expect(silent.ok, silent.error).toBe(true); + let silentBytes = await write(realmUrl, 'silent.png', TINY_PNG_BYTES, { + profileManager, + realmEvents: false, + }); + expect(silentBytes.ok, silentBytes.error).toBe(true); + } finally { + fetchSpy.mockRestore(); + } + + expect(markers).toEqual([null, 'none', 'none']); + expect(await (await readBack('silent.gts')).text()).toContain('s = 1'); + }); + + it('--no-realm-events writes the file', async () => { + let res = await runBoxel( + [ + 'file', + 'write', + 'flagged.gts', + '--realm', + realmUrl, + '--no-realm-events', + ], + { home, input: 'export const flagged = 1;' }, + ); + expect(res.ok, res.stderr).toBe(true); + expect(await (await readBack('flagged.gts')).text()).toContain('flagged'); + }); }); diff --git a/packages/host/app/services/card-service.ts b/packages/host/app/services/card-service.ts index 8518798e421..2773c31a0c0 100644 --- a/packages/host/app/services/card-service.ts +++ b/packages/host/app/services/card-service.ts @@ -6,6 +6,7 @@ import { v4 as uuidv4 } from 'uuid'; import { formattedError, isJsonContentType, + REALM_EVENTS_HEADER, SupportedMimeType, type CardDocument, type SingleCardDocument, @@ -438,7 +439,14 @@ export default class CardService extends Service { async executeAtomicOperations( operations: AtomicOperation[], realm: RealmIdentifier | URL, - opts?: { clientRequestId?: string }, + opts?: { + clientRequestId?: string; + // False for a write the system makes on its own, such as a scheduled + // sync of a public realm's content. The realm indexes it as usual but + // announces nothing to its subscribers, so no open tab resets its loader + // for a change its user did not ask for (see `REALM_EVENTS_HEADER`). + realmEvents?: boolean; + }, ) { let realmURL = this.toURL(realm); for (let operation of operations) { @@ -463,6 +471,9 @@ export default class CardService extends Service { ...(opts?.clientRequestId ? { 'X-Boxel-Client-Request-Id': opts.clientRequestId } : {}), + ...(opts?.realmEvents === false + ? { [REALM_EVENTS_HEADER]: 'none' } + : {}), }, body: JSON.stringify(doc), }); diff --git a/packages/host/app/tools/sync-openrouter-models.ts b/packages/host/app/tools/sync-openrouter-models.ts index 487e1b44f10..ca889d0aecb 100644 --- a/packages/host/app/tools/sync-openrouter-models.ts +++ b/packages/host/app/tools/sync-openrouter-models.ts @@ -271,6 +271,8 @@ export default class SyncOpenRouterModelsTool extends HostBaseTool< }); } + // The sync is a scheduled refresh of a public realm that no reader asked + // for, so its writes announce nothing to the realm's subscribers. private async executeBatchWithRetry( batch: AtomicOperation[], realmURL: URL, @@ -278,6 +280,7 @@ export default class SyncOpenRouterModelsTool extends HostBaseTool< let result = await this.cardService.executeAtomicOperations( batch, realmURL, + { realmEvents: false }, ); if (!result.errors) { @@ -325,6 +328,7 @@ export default class SyncOpenRouterModelsTool extends HostBaseTool< let retryResult = await this.cardService.executeAtomicOperations( retryBatch, realmURL, + { realmEvents: false }, ); if (retryResult.errors) { diff --git a/packages/realm-server/handlers/handle-full-reindex.ts b/packages/realm-server/handlers/handle-full-reindex.ts index bb5c95ed3ac..711203a33ce 100644 --- a/packages/realm-server/handlers/handle-full-reindex.ts +++ b/packages/realm-server/handlers/handle-full-reindex.ts @@ -8,6 +8,9 @@ import { setContextResponse } from '../middleware/index.ts'; import { getFullReindexRealmUrls } from '../lib/full-reindex-realm-urls.ts'; import type { CreateRoutesArgs } from '../routes.ts'; +// An operator's reindex of every realm. None of the realms' users asked for +// it, so no realm's pass announces anything to its subscribers (see +// `fromScratchAnnounces`). export default function handleFullReindex({ dbAdapter, queue, @@ -25,6 +28,7 @@ export default function handleFullReindex({ priority: systemInitiatedPriority, args: { realmUrls, + realmEvents: false, }, }); await setContextResponse( diff --git a/packages/realm-server/handlers/handle-post-deployment.ts b/packages/realm-server/handlers/handle-post-deployment.ts index 86c15eee701..dac22d458e2 100644 --- a/packages/realm-server/handlers/handle-post-deployment.ts +++ b/packages/realm-server/handlers/handle-post-deployment.ts @@ -45,6 +45,9 @@ export default function handlePostDeployment({ ) { let realmUrls = await getFullReindexRealmUrls(dbAdapter); + // A deploy's reindex: no realm's users asked for it, so no realm's pass + // announces anything to its subscribers (see + // `fromScratchAnnounces`). await queue.publish({ jobType: `full-reindex`, concurrencyGroup: `full-reindex-group`, @@ -52,6 +55,7 @@ export default function handlePostDeployment({ priority: systemInitiatedPriority, args: { realmUrls, + realmEvents: false, }, }); diff --git a/packages/realm-server/handlers/handle-reindex.ts b/packages/realm-server/handlers/handle-reindex.ts index b080326505c..0f98a428de3 100644 --- a/packages/realm-server/handlers/handle-reindex.ts +++ b/packages/realm-server/handlers/handle-reindex.ts @@ -101,6 +101,9 @@ export default function handleReindex({ }; } +// An operator's reindex, which the realm's users did not ask for, so it +// announces nothing to the realm's subscribers (see +// `fromScratchAnnounces`). export async function reindex({ realm, queue, @@ -123,6 +126,7 @@ export async function reindex({ priority, { clearLastModified: true, + realmEvents: false, }, ); } diff --git a/packages/realm-server/tests/atomic-endpoints-test.ts b/packages/realm-server/tests/atomic-endpoints-test.ts index 8112daaf63f..64a1a32ad20 100644 --- a/packages/realm-server/tests/atomic-endpoints-test.ts +++ b/packages/realm-server/tests/atomic-endpoints-test.ts @@ -1233,6 +1233,54 @@ module(basename(import.meta.filename), function () { ); }); + test('an ignore-file write sent with X-Boxel-Realm-Events: none reindexes the realm without any realm event', async function (assert) { + let auth = `Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`; + let writeIgnoreFile = async ( + content: string, + headers: Record, + ) => { + let pending = request + .post('/.boxelignore') + .set('Accept', SupportedMimeType.CardSource) + .set('Content-Type', 'text/plain;charset=UTF-8') + .set('Authorization', auth); + for (let [name, value] of Object.entries(headers)) { + pending = pending.set(name, value); + } + let response = await pending.send(content); + assert.strictEqual(response.status, 204); + }; + + let events = recordRealmEvents(); + await writeIgnoreFile('quiet-ignored.json\n', { + 'X-Boxel-Realm-Events': 'none', + }); + // The write's own pass, then the from-scratch pass it starts. + await testRealm.indexing(); + await testRealm.indexing(); + // The reindex announces right after its pass settles. + await new Promise((resolve) => setTimeout(resolve, 250)); + assert.deepEqual( + eventKinds(events), + [], + 'neither the write nor the reindex it started sent a realm event', + ); + + await writeIgnoreFile('loud-ignored.json\n', {}); + await waitUntil( + async () => eventKinds(events).includes('index:full'), + { + timeout: 30000, + timeoutMessage: () => + `no full index event; saw ${JSON.stringify(eventKinds(events))}`, + }, + ); + assert.ok( + eventKinds(events).includes('index:full'), + 'an ignore-file write without the header announces the reindex', + ); + }); + test('removing several files at once sends one initiation', async function (assert) { await postAtomic(peopleBatch(['gone-a', 'gone-b', 'gone-c'])); await testRealm.incrementalIndexing(); diff --git a/packages/realm-server/tests/full-reindex-test.ts b/packages/realm-server/tests/full-reindex-test.ts index faf9a6b96bb..1fc01ab0976 100644 --- a/packages/realm-server/tests/full-reindex-test.ts +++ b/packages/realm-server/tests/full-reindex-test.ts @@ -188,6 +188,25 @@ module(basename(import.meta.filename), function (hooks) { ); }); + test('a sweep that announces nothing enqueues passes that announce nothing', async function (assert) { + const sourceRealmURL = 'http://example.com/source/'; + await insertPermissions(dbAdapter, new URL(sourceRealmURL), { + '@owner:localhost': ['read', 'realm-owner'], + }); + + let reindex = buildFullReindexTask(); + await reindex({ realmUrls: [sourceRealmURL], realmEvents: false }); + + let jobs = (await dbAdapter.execute('select * from jobs')) as { + args: { realmURL: string; realmEvents?: boolean }; + }[]; + assert.strictEqual(jobs.length, 1, 'a from-scratch job was enqueued'); + assert.false( + jobs[0].args.realmEvents, + "the realm's pass announces nothing to its subscribers", + ); + }); + test('enqueues the base realm above the system tier, every other realm at it', async function (assert) { const sourceRealmURL = 'http://example.com/source/'; diff --git a/packages/realm-server/tests/queue-test.ts b/packages/realm-server/tests/queue-test.ts index 01d1fb766e9..b80b9ebf4d8 100644 --- a/packages/realm-server/tests/queue-test.ts +++ b/packages/realm-server/tests/queue-test.ts @@ -964,6 +964,125 @@ module(basename(import.meta.filename), function () { } }); + test('from-scratch dedup: a pass that announces does not attach to a running pass that announces nothing', async function (assert) { + // A running job's args can no longer change, so a publish attaching to + // a silent pass would never have its pass announced. + await runner.destroy(); + let realmURL = 'http://example.com/from-scratch-in-flight-silent/'; + let started = new Deferred(); + let release = new Deferred(); + + let worker = new PgQueueRunner({ + adapter, + workerId: 'from-scratch-in-flight-silent-worker', + }); + worker.register('from-scratch-index', async () => { + started.fulfill(); + await release.promise; + return { + invalidations: [], + ignoreData: {}, + stats: { + instancesIndexed: 0, + filesIndexed: 0, + instanceErrors: 0, + fileErrors: 0, + totalIndexEntries: 0, + }, + }; + }); + + try { + await worker.start(); + let running = await publishFromScratchIndexJob({ + priority: 0, + args: { realmURL, realmUsername: 'owner', realmEvents: false }, + }); + await started.promise; + + let silent = await publishFromScratchIndexJob({ + priority: 0, + args: { realmURL, realmUsername: 'owner', realmEvents: false }, + }); + assert.strictEqual( + silent.id, + running.id, + 'a pass that announces nothing attaches to the running pass', + ); + + let announcing = await publishFromScratchIndexJob({ + priority: 0, + args: { realmURL, realmUsername: 'owner' }, + }); + assert.notStrictEqual( + announcing.id, + running.id, + 'a pass that announces waits for a pass that will announce it', + ); + } finally { + release.fulfill(); + await worker.destroy(); + } + }); + + test('from-scratch coalesce: the merged pass announces when either pass does', async function (assert) { + await runner.destroy(); + let pendingArgs = async (realmURL: string) => { + let rows = (await adapter.execute( + `SELECT args FROM jobs + WHERE job_type = 'from-scratch-index' + AND concurrency_group = $1 + AND status = 'unfulfilled'`, + { bind: [`indexing:${realmURL}`] }, + )) as { args: { realmEvents?: boolean } }[]; + assert.strictEqual(rows.length, 1, `one pending pass for ${realmURL}`); + return rows[0].args; + }; + + let mixedRealm = 'http://example.com/from-scratch-merge-mixed/'; + let silentThenAnnouncing = await publishFromScratchIndexJob({ + priority: 0, + args: { + realmURL: mixedRealm, + realmUsername: 'owner', + realmEvents: false, + }, + }); + let announcingJoin = await publishFromScratchIndexJob({ + priority: 0, + args: { realmURL: mixedRealm, realmUsername: 'owner' }, + }); + assert.strictEqual(announcingJoin.id, silentThenAnnouncing.id); + assert.notStrictEqual( + (await pendingArgs(mixedRealm)).realmEvents, + false, + 'a silent pass joined by one that announces announces', + ); + + let silentRealm = 'http://example.com/from-scratch-merge-silent/'; + let firstSilent = await publishFromScratchIndexJob({ + priority: 0, + args: { + realmURL: silentRealm, + realmUsername: 'owner', + realmEvents: false, + }, + }); + let secondSilent = await publishFromScratchIndexJob({ + priority: 0, + args: { + realmURL: silentRealm, + realmUsername: 'owner', + realmEvents: false, + }, + }); + assert.strictEqual(secondSilent.id, firstSilent.id); + assert.false( + (await pendingArgs(silentRealm)).realmEvents, + 'two silent passes merge into a silent pass', + ); + }); + test('from-scratch does not coalesce onto pending incremental in same group', async function (assert) { await runner.destroy(); @@ -1810,6 +1929,45 @@ module(basename(import.meta.filename), function () { "canonical job args contain the union of both instances' realmUrls", ); }); + + test('full-reindex: a merged sweep announces when either sweep does, and is silent when both are', async function (assert) { + let publishSweep = ( + from: typeof publisher, + realmEvents: boolean | undefined, + ) => + from.publish({ + jobType: 'full-reindex', + concurrencyGroup: 'full-reindex-group', + timeout: FULL_REINDEX_JOB_TIMEOUT_SEC, + priority: 0, + args: { + realmUrls: ['http://example.com/a/'], + ...(realmEvents === undefined ? {} : { realmEvents }), + }, + }); + let pendingRealmEvents = async () => { + let rows = (await adapter.execute( + `SELECT args FROM jobs + WHERE job_type = 'full-reindex' AND status = 'unfulfilled'`, + )) as { args: { realmEvents?: boolean } }[]; + assert.strictEqual(rows.length, 1, 'one pending sweep'); + return rows[0].args.realmEvents; + }; + + await publishSweep(publisher, false); + await publishSweep(publisher2, false); + assert.false( + await pendingRealmEvents(), + 'two silent sweeps merge into a silent sweep', + ); + + await publishSweep(publisher2, undefined); + assert.notStrictEqual( + await pendingRealmEvents(), + false, + 'a sweep that announces makes the merged sweep announce', + ); + }); }, ); diff --git a/packages/realm-server/tests/server-endpoints/maintenance-endpoints-test.ts b/packages/realm-server/tests/server-endpoints/maintenance-endpoints-test.ts index aba2f3a3f31..72796e00b95 100644 --- a/packages/realm-server/tests/server-endpoints/maintenance-endpoints-test.ts +++ b/packages/realm-server/tests/server-endpoints/maintenance-endpoints-test.ts @@ -704,6 +704,9 @@ module(`server-endpoints/${basename(import.meta.filename)}`, function () { // coalesce can refuse to attach this kind of publish to an // already-running same-realm from-scratch. clearLastModified: true, + // An operator's reindex announces nothing to the realm's + // subscribers. + realmEvents: false, }, 'realm args are correct', ); @@ -818,6 +821,7 @@ module(`server-endpoints/${basename(import.meta.filename)}`, function () { realmURL, realmUsername: owner, clearLastModified: true, + realmEvents: false, }, 'job args target the evicted realm', ); @@ -985,7 +989,7 @@ module(`server-endpoints/${basename(import.meta.filename)}`, function () { job_type: string; concurrency_group: string; timeout: number; - args: { realmUrls: string[] }; + args: { realmUrls: string[]; realmEvents?: boolean }; } | undefined; assert.ok(reindexJob, 'full-reindex job exists'); @@ -1008,6 +1012,10 @@ module(`server-endpoints/${basename(import.meta.filename)}`, function () { reindexJob.args.realmUrls.includes(testRealmURL.href), 'job args still include mounted realms', ); + assert.false( + reindexJob.args.realmEvents, + "a deploy's reindex announces nothing to any realm's subscribers", + ); } assert.ok( @@ -1214,13 +1222,17 @@ module(`server-endpoints/${basename(import.meta.filename)}`, function () { let jobs = finalJobs.slice(2) as { job_type: string; concurrency_group: string; - args: { realmUrls: string[] }; + args: { realmUrls: string[]; realmEvents?: boolean }; }[]; assert.strictEqual( jobs[0].job_type, 'full-reindex', 'job type is correct', ); + assert.false( + jobs[0].args.realmEvents, + "an operator's full reindex announces nothing to any realm's subscribers", + ); assert.strictEqual( jobs[0].concurrency_group, `full-reindex-group`, diff --git a/packages/runtime-common/jobs/reindex-realm.ts b/packages/runtime-common/jobs/reindex-realm.ts index 8db29afd3ee..bb152be36fd 100644 --- a/packages/runtime-common/jobs/reindex-realm.ts +++ b/packages/runtime-common/jobs/reindex-realm.ts @@ -14,6 +14,11 @@ interface EnqueueReindexRealmJobOptions { // prerenderHtmlPriority). Surfaced in the from-scratch args, and carried // only when true so ordinary index args keep their existing shape. awaitedByPublish?: boolean; + // False for a pass the system starts on its own, such as a deploy or an + // operator's reindex, so it announces nothing to the realm's subscribers + // (see `fromScratchAnnounces`). Carried only when false so ordinary + // index args keep their existing shape. + realmEvents?: boolean; } export async function enqueueReindexRealmJob( @@ -33,6 +38,7 @@ export async function enqueueReindexRealmJob( realmUsername, clearLastModified, ...(opts?.awaitedByPublish ? { awaitedByPublish: true } : {}), + ...(opts?.realmEvents === false ? { realmEvents: false } : {}), }; if (clearLastModified) { await query(dbAdapter, [ diff --git a/packages/runtime-common/realm-index-updater.ts b/packages/runtime-common/realm-index-updater.ts index 969cf623e52..12d50b31829 100644 --- a/packages/runtime-common/realm-index-updater.ts +++ b/packages/runtime-common/realm-index-updater.ts @@ -461,17 +461,19 @@ export class RealmIndexUpdater { } // Set by the realm, which runs the pass through its own reindex so the - // pass's invalidations reach its subscribers and its peer replicas. - onIgnoreRulesChanged: (() => void) | undefined; + // pass's invalidations reach its subscribers and its peer replicas. The + // pass announces only if the write that changed the rules did (see + // `IncrementalIndexOptions.realmEvents`). + onIgnoreRulesChanged: ((opts: { realmEvents?: boolean }) => void) | undefined; // Not awaited by the write that changed the rules: the gates a request // waits on never cover a from-scratch pass (see `indexing`). - #rereadIgnoreRules() { + #rereadIgnoreRules(opts: { realmEvents?: boolean }) { if (this.onIgnoreRulesChanged) { - this.onIgnoreRulesChanged(); + this.onIgnoreRulesChanged(opts); return; } - this.publishFullIndex().completed.catch((e: any) => { + this.publishFullIndex(undefined, opts).completed.catch((e: any) => { this.#log.warn( `Rereading the ignore rules of ${this.realmURL.href} failed: ${e?.message}`, ); @@ -480,7 +482,12 @@ export class RealmIndexUpdater { publishFullIndex( priority = systemInitiatedPriority, - opts?: { clearLastModified?: boolean; awaitedByPublish?: boolean }, + opts?: { + clearLastModified?: boolean; + awaitedByPublish?: boolean; + // See `fromScratchAnnounces`. + realmEvents?: boolean; + }, ): { published: Promise>; completed: Promise; @@ -504,6 +511,7 @@ export class RealmIndexUpdater { { clearLastModified: opts?.clearLastModified, awaitedByPublish: opts?.awaitedByPublish, + realmEvents: opts?.realmEvents, }, ); pendingPass.jobId = job.id; @@ -541,8 +549,11 @@ export class RealmIndexUpdater { }; } - async fullIndex(priority = systemInitiatedPriority) { - let { completed } = this.publishFullIndex(priority); + async fullIndex( + priority = systemInitiatedPriority, + opts?: { realmEvents?: boolean }, + ) { + let { completed } = this.publishFullIndex(priority, opts); try { await completed; } catch (e: any) { @@ -683,7 +694,7 @@ export class RealmIndexUpdater { // root ignore files takes a from-scratch pass: it rereads the rules, // tombstones what they now hide and indexes what they now reveal. if (changes.some(({ url }) => this.#isRealmIgnoreFile(url))) { - this.#rereadIgnoreRules(); + this.#rereadIgnoreRules({ realmEvents: opts?.realmEvents }); } if (opts?.onInvalidation) { await opts.onInvalidation( diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 6d5bedca5d7..9350255937b 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -3162,8 +3162,8 @@ export class Realm { dbAdapter, queue, }); - this.#realmIndexUpdater.onIgnoreRulesChanged = () => - this.#reindexForIgnoreRules(); + this.#realmIndexUpdater.onIgnoreRulesChanged = (opts) => + this.#reindexForIgnoreRules(opts); this.#realmIndexQueryEngine = new RealmIndexQueryEngine({ realm: this, dbAdapter, @@ -3780,12 +3780,17 @@ export class Realm { private startReindex(opts?: { clearLastModified?: boolean; priority?: number; + // See `fromScratchAnnounces`. False also withholds this method's + // own invalidation and `full` events. + realmEvents?: boolean; }): { published: Promise>; completed: Promise } { + let realmEvents = opts?.realmEvents !== false; let { published, completed: indexingCompleted } = this.#realmIndexUpdater.publishFullIndex( opts?.priority ?? systemInitiatedPriority, { clearLastModified: opts?.clearLastModified, + realmEvents, }, ); @@ -3812,17 +3817,19 @@ export class Realm { `dropAllTranspiledModuleCacheEntries failed after reindex of ${this.url}: ${String(err)}`, ); } - if (invalidations.length > 0) { - this.broadcastIncrementalInvalidationEvent(invalidations, { - generation, + if (realmEvents) { + if (invalidations.length > 0) { + this.broadcastIncrementalInvalidationEvent(invalidations, { + generation, + }); + } + this.broadcastRealmEvent({ + eventName: 'index', + indexType: 'full', + ...(generation !== undefined ? { generation } : {}), + realmURL: this.url, }); } - this.broadcastRealmEvent({ - eventName: 'index', - indexType: 'full', - ...(generation !== undefined ? { generation } : {}), - realmURL: this.url, - }); try { await this.#definitionLookup.clearRealmDefinitions(this.url); } catch (err: unknown) { @@ -8027,18 +8034,28 @@ export class Realm { } let priority = opts?.fromScratchIndexPriority ?? this.#fromScratchIndexPriority; - let promise = this.#realmIndexUpdater.fullIndex(priority); + // Reindexing an existing index at boot is how a deploy delivers the + // content a bootstrap realm's image ships, so it is announced to nobody: + // tabs open on the realm pick the new content up on their next load + // rather than reset their loader mid-session (see + // `fromScratchAnnounces`). A brand-new index still announces. + let realmEvents = isNewIndex || !this.#fullIndexOnStartup; + let promise = this.#realmIndexUpdater.fullIndex(priority, { + realmEvents, + }); if (isNewIndex) { // we only await the full indexing at boot if this is a brand new index await promise; } - // not sure how useful this event is--nothing is currently listening for - // it, and it may happen during or after the full index... - this.broadcastRealmEvent({ - eventName: 'index', - indexType: 'full', - realmURL: this.url, - }); + if (realmEvents) { + // not sure how useful this event is--nothing is currently listening + // for it, and it may happen during or after the full index... + this.broadcastRealmEvent({ + eventName: 'index', + indexType: 'full', + realmURL: this.url, + }); + } } else { await this.#loadIgnoreRules(); } @@ -8051,8 +8068,8 @@ export class Realm { // A changed root ignore file: the from-scratch pass rereads the rules and // broadcasts what it hid or revealed, and the cache wipe after it tells the // peer replicas to reread the rules too. - #reindexForIgnoreRules() { - let { completed } = this.startReindex(); + #reindexForIgnoreRules(opts: { realmEvents?: boolean }) { + let { completed } = this.startReindex(opts); completed .then(() => this.clearRealmIndexCachesAndBroadcast()) .catch(() => { diff --git a/packages/runtime-common/tasks/full-reindex.ts b/packages/runtime-common/tasks/full-reindex.ts index c0ed82b22cf..485ca0e345e 100644 --- a/packages/runtime-common/tasks/full-reindex.ts +++ b/packages/runtime-common/tasks/full-reindex.ts @@ -18,6 +18,10 @@ export interface RealmReindexTarget extends JSONTypes.Object { interface FullReindexArgs { realmUrls: string[]; + // False when the system starts the sweep on its own, after a deploy or from + // an operator's dashboard: each realm's pass then announces nothing to its + // subscribers (see `fromScratchAnnounces`). True when absent. + realmEvents?: boolean; } export { fullReindex }; @@ -68,21 +72,28 @@ function chooseFullReindexCoalesceDecision( let merged = [ ...new Set([...getRealmUrls(twin.args), ...getRealmUrls(incoming.args)]), ]; + let { realmEvents: _realmEvents, ...args } = { + ...(isObjectLike(twin.args) ? twin.args : {}), + ...(isObjectLike(incoming.args) ? incoming.args : {}), + realmUrls: merged, + } as JSONTypes.Object; + // One sweep that announces is enough for the merged sweep to announce. + let announces = announcesSweep(twin.args) || announcesSweep(incoming.args); return { type: 'join', jobId: twin.id, update: { priority: Math.max(twin.priority, incoming.priority), timeout: Math.max(twin.timeout, incoming.timeout), - args: { - ...(isObjectLike(twin.args) ? twin.args : {}), - ...(isObjectLike(incoming.args) ? incoming.args : {}), - realmUrls: merged, - }, + args: announces ? args : { ...args, realmEvents: false }, }, }; } +function announcesSweep(args: unknown): boolean { + return !(isObjectLike(args) && args.realmEvents === false); +} + registerQueueJobDefinition({ jobType: 'full-reindex', coalesce: chooseFullReindexCoalesceDecision, @@ -95,7 +106,7 @@ const fullReindex: Task = ({ queuePublisher, }) => async function (args) { - let { jobInfo, realmUrls } = args; + let { jobInfo, realmUrls, realmEvents } = args; log.debug( `${jobIdentity(jobInfo)} starting reindex-all for job: ${JSON.stringify(args)}`, ); @@ -149,6 +160,7 @@ const fullReindex: Task = ({ systemInitiatedIndexPriority(realmUrl), { clearLastModified: true, + realmEvents, }, ); } catch (error: any) { diff --git a/packages/runtime-common/tasks/indexer.ts b/packages/runtime-common/tasks/indexer.ts index 64eb49ffa91..ed78f437f2a 100644 --- a/packages/runtime-common/tasks/indexer.ts +++ b/packages/runtime-common/tasks/indexer.ts @@ -501,16 +501,20 @@ function chooseFromScratchCoalesceDecision( // indexing of bytes it wrote. A publish naming nobody adds nobody, // which leaves an all-untagged job still reading as the realm owner. initiatedBy: mergeInitiators(sameTypeCandidate, incoming), - args: { - ...(isObjectLike(sameTypeCandidate.args) - ? sameTypeCandidate.args - : {}), - ...(isObjectLike(incoming.args) ? incoming.args : {}), - coalescedCallers: mergeCoalescedCallers( - getCoalescedCallers(sameTypeCandidate.args), - getCoalescedCallers(incoming.args), - ), - }, + args: withFromScratchRealmEvents( + { + ...(isObjectLike(sameTypeCandidate.args) + ? sameTypeCandidate.args + : {}), + ...(isObjectLike(incoming.args) ? incoming.args : {}), + coalescedCallers: mergeCoalescedCallers( + getCoalescedCallers(sameTypeCandidate.args), + getCoalescedCallers(incoming.args), + ), + }, + fromScratchAnnounces(sameTypeCandidate.args) || + fromScratchAnnounces(incoming.args), + ), }, }; } @@ -534,12 +538,20 @@ function chooseFromScratchCoalesceDecision( // pass a realm publish enqueues (`awaitedByPublish`), with or without the // clear: the publish has just swapped in new files, and an already-running // pass may have read the file mtimes before that swap. + // + // A pass that announces never rides on a running one that doesn't: a + // running job's args can no longer change, so its subscribers would never + // hear of a pass someone asked to announce. if ( !incomingClearsLastModified(incoming.args) && !argsAwaitedByPublish(incoming.args) ) { for (let candidate of inFlightCandidates) { - if (candidate.jobType === incoming.jobType) { + if ( + candidate.jobType === incoming.jobType && + (fromScratchAnnounces(candidate.args) || + !fromScratchAnnounces(incoming.args)) + ) { return { type: 'join', jobId: candidate.id }; } } @@ -548,6 +560,28 @@ function chooseFromScratchCoalesceDecision( return { type: 'insert' }; } +// Whether a from-scratch pass announces anything to the realm's subscribers, +// read from the `realmEvents` key of its args. The key is false for a pass the +// system starts on its own: a deploy of a realm's content, the post-deploy +// reindex of every realm, an operator's reindex. Indexing that a user's own +// session did not ask for must not reset that session's loader mid-use, so +// such a pass announces nothing: its render job sends no `prerender_html` +// event and its follow-up passes send no `incremental` event. Open tabs pick +// the result up on their next load. Peer replicas are still told through +// NOTIFY. The key is carried only when false, so an absent key announces, and +// it is OR-merged on coalesce like `IncrementalArgs.realmEvents`. +export function fromScratchAnnounces(args: unknown): boolean { + return !(isObjectLike(args) && args.realmEvents === false); +} + +function withFromScratchRealmEvents( + args: JSONTypes.Object, + announces: boolean, +): JSONTypes.Object { + let { realmEvents: _realmEvents, ...rest } = args; + return announces ? rest : { ...rest, realmEvents: false }; +} + function incomingClearsLastModified(args: unknown): boolean { return isObjectLike(args) && args.clearLastModified === true; } @@ -695,6 +729,7 @@ const fromScratchIndex: Task = ({ // From-scratch: the prerender job runs the realm-wide module // pre-warm sweep before its format renders. preWarm: true, + announce: fromScratchAnnounces(args), // The pass is its family's exclusive work, so the render is too. lane: prerenderHtmlLaneFollowing(realmURL, jobInfo), }).catch((e) => { @@ -703,7 +738,11 @@ const fromScratchIndex: Task = ({ ); }); }, - followUpJobArgs: followUpIncrementalArgs(realmURL, realmUsername, true), + followUpJobArgs: followUpIncrementalArgs( + realmURL, + realmUsername, + fromScratchAnnounces(args), + ), auth, fetch: _fetch, prerenderer, From 55257206485389f3031b1b283427d377d1cfce6e Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 22 Oct 2026 09:13:06 -0400 Subject: [PATCH 2/3] Address review of silent system indexing - full-reindex sweeps merge only with sweeps that make the same choice about announcing, so an unarchive's announcing sweep can't make a pending post-deploy sweep announce for every realm - the boot index announces only a brand-new index (and only after it lands); comments on the ignore-file reindex and the host's `full` handling say when they announce - the .boxelignore test checks that the spawned reindex and render jobs carry no announcement, since the test worker reports no realm events - the CLI flag test is named for what it checks Co-Authored-By: Claude Opus 5.5 --- .../tests/integration/file-write.test.ts | 4 +- packages/host/app/services/store.ts | 10 +-- .../tests/atomic-endpoints-test.ts | 32 +++++++++ packages/realm-server/tests/queue-test.ts | 71 +++++++++++++------ packages/runtime-common/realm.ts | 26 ++++--- packages/runtime-common/tasks/full-reindex.ts | 20 +++--- 6 files changed, 115 insertions(+), 48 deletions(-) diff --git a/packages/boxel-cli/tests/integration/file-write.test.ts b/packages/boxel-cli/tests/integration/file-write.test.ts index 6bf0d657eec..004e8ec6e21 100644 --- a/packages/boxel-cli/tests/integration/file-write.test.ts +++ b/packages/boxel-cli/tests/integration/file-write.test.ts @@ -256,7 +256,9 @@ describe('file write (integration)', () => { expect(await (await readBack('silent.gts')).text()).toContain('s = 1'); }); - it('--no-realm-events writes the file', async () => { + // The header itself is checked in-process above; this checks that the CLI + // accepts the flag and still writes. + it('accepts --no-realm-events and writes the file', async () => { let res = await runBoxel( [ 'file', diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index 72917ea5fb4..3e63676d4fa 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -2565,10 +2565,12 @@ export default class StoreService extends Service implements StoreInterface { if (event.indexType === 'full') { // A full reindex carries no per-file invalidation list, so there is // nothing to reload by name. A realm that reindexes on request does - // broadcast the URLs it visited as an incremental event first, but one - // reindexing at startup announces itself with this event alone — so this - // can be the only word a card being held as awaiting-index ever gets - // that the row it is waiting for now exists. + // broadcast the URLs it visited as an incremental event first, but a + // realm building a brand-new index at startup announces itself with + // this event alone — so this can be the only word a card being held as + // awaiting-index ever gets that the row it is waiting for now exists. + // A realm reindexing an existing index at startup, as on a deploy, + // announces nothing. let reloadsTriggered = this.reloadAwaitingIndexInstances(event.realmURL); // Report the pass as a thin realm-event so the dashboard still sees it. telemetry?.recordEvent({ diff --git a/packages/realm-server/tests/atomic-endpoints-test.ts b/packages/realm-server/tests/atomic-endpoints-test.ts index 64a1a32ad20..4c907d27f6d 100644 --- a/packages/realm-server/tests/atomic-endpoints-test.ts +++ b/packages/realm-server/tests/atomic-endpoints-test.ts @@ -1251,6 +1251,9 @@ module(basename(import.meta.filename), function () { assert.strictEqual(response.status, 204); }; + let [{ max: jobsBefore }] = (await dbAdapter.execute( + 'SELECT coalesce(max(id), 0) AS max FROM jobs', + )) as { max: number }[]; let events = recordRealmEvents(); await writeIgnoreFile('quiet-ignored.json\n', { 'X-Boxel-Realm-Events': 'none', @@ -1265,6 +1268,35 @@ module(basename(import.meta.filename), function () { [], 'neither the write nor the reindex it started sent a realm event', ); + // What the worker would announce is carried by the jobs the pass + // spawned: its render job and any follow-up pass. + let spawned = (await dbAdapter.execute( + `SELECT job_type, args FROM jobs + WHERE id > $1 + AND job_type IN ('from-scratch-index', 'prerender_html') + ORDER BY id`, + { bind: [jobsBefore] }, + )) as { + job_type: string; + args: { realmEvents?: boolean; announce?: boolean }; + }[]; + let reindex = spawned.find( + ({ job_type }) => job_type === 'from-scratch-index', + ); + assert.ok(reindex, 'the ignore-file write started a reindex'); + assert.false( + reindex?.args.realmEvents, + 'the reindex announces nothing', + ); + assert.deepEqual( + spawned + .filter(({ job_type }) => job_type === 'prerender_html') + .map(({ args }) => args.announce), + spawned + .filter(({ job_type }) => job_type === 'prerender_html') + .map(() => false), + 'no render job the passes spawned announces', + ); await writeIgnoreFile('loud-ignored.json\n', {}); await waitUntil( diff --git a/packages/realm-server/tests/queue-test.ts b/packages/realm-server/tests/queue-test.ts index b80b9ebf4d8..0ba86e3f94d 100644 --- a/packages/realm-server/tests/queue-test.ts +++ b/packages/realm-server/tests/queue-test.ts @@ -1930,9 +1930,10 @@ module(basename(import.meta.filename), function () { ); }); - test('full-reindex: a merged sweep announces when either sweep does, and is silent when both are', async function (assert) { + test('full-reindex: sweeps merge only with sweeps that make the same choice about announcing', async function (assert) { let publishSweep = ( from: typeof publisher, + realmUrl: string, realmEvents: boolean | undefined, ) => from.publish({ @@ -1941,31 +1942,61 @@ module(basename(import.meta.filename), function () { timeout: FULL_REINDEX_JOB_TIMEOUT_SEC, priority: 0, args: { - realmUrls: ['http://example.com/a/'], + realmUrls: [realmUrl], ...(realmEvents === undefined ? {} : { realmEvents }), }, }); - let pendingRealmEvents = async () => { - let rows = (await adapter.execute( - `SELECT args FROM jobs - WHERE job_type = 'full-reindex' AND status = 'unfulfilled'`, - )) as { args: { realmEvents?: boolean } }[]; - assert.strictEqual(rows.length, 1, 'one pending sweep'); - return rows[0].args.realmEvents; - }; - - await publishSweep(publisher, false); - await publishSweep(publisher2, false); - assert.false( - await pendingRealmEvents(), - 'two silent sweeps merge into a silent sweep', + let pendingSweeps = async () => + (await adapter.execute( + `SELECT id, args FROM jobs + WHERE job_type = 'full-reindex' AND status = 'unfulfilled' + ORDER BY id`, + )) as { + id: number; + args: { realmUrls: string[]; realmEvents?: boolean }; + }[]; + + let silent = await publishSweep( + publisher, + 'http://example.com/a/', + false, + ); + let secondSilent = await publishSweep( + publisher2, + 'http://example.com/b/', + false, + ); + assert.strictEqual( + secondSilent.id, + silent.id, + 'two silent sweeps merge', ); - await publishSweep(publisher2, undefined); + let announcing = await publishSweep( + publisher2, + 'http://example.com/c/', + undefined, + ); assert.notStrictEqual( - await pendingRealmEvents(), - false, - 'a sweep that announces makes the merged sweep announce', + announcing.id, + silent.id, + 'a sweep that announces does not merge into a silent one', + ); + + let sweeps = await pendingSweeps(); + assert.deepEqual( + sweeps.map(({ args }) => ({ + realmUrls: [...args.realmUrls].sort(), + realmEvents: args.realmEvents, + })), + [ + { + realmUrls: ['http://example.com/a/', 'http://example.com/b/'], + realmEvents: false, + }, + { realmUrls: ['http://example.com/c/'], realmEvents: undefined }, + ], + "each sweep keeps its own realms' choice", ); }); }, diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 9350255937b..f68fe27a480 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -8034,22 +8034,19 @@ export class Realm { } let priority = opts?.fromScratchIndexPriority ?? this.#fromScratchIndexPriority; - // Reindexing an existing index at boot is how a deploy delivers the - // content a bootstrap realm's image ships, so it is announced to nobody: - // tabs open on the realm pick the new content up on their next load - // rather than reset their loader mid-session (see - // `fromScratchAnnounces`). A brand-new index still announces. - let realmEvents = isNewIndex || !this.#fullIndexOnStartup; + // Only a brand-new index is announced. Reindexing an existing index at + // boot is how a deploy delivers the content a bootstrap realm's image + // ships, so it is announced to nobody: tabs open on the realm pick the + // new content up on their next load rather than reset their loader + // mid-session (see `fromScratchAnnounces`). + let realmEvents = isNewIndex; let promise = this.#realmIndexUpdater.fullIndex(priority, { realmEvents, }); if (isNewIndex) { - // we only await the full indexing at boot if this is a brand new index + // we only await the full indexing at boot if this is a brand new + // index, so this event follows the pass await promise; - } - if (realmEvents) { - // not sure how useful this event is--nothing is currently listening - // for it, and it may happen during or after the full index... this.broadcastRealmEvent({ eventName: 'index', indexType: 'full', @@ -8065,9 +8062,10 @@ export class Realm { ); } - // A changed root ignore file: the from-scratch pass rereads the rules and - // broadcasts what it hid or revealed, and the cache wipe after it tells the - // peer replicas to reread the rules too. + // A changed root ignore file: the from-scratch pass rereads the rules and, + // when the write that changed them announces itself, broadcasts what it hid + // or revealed. The cache wipe after it tells the peer replicas to reread + // the rules either way. #reindexForIgnoreRules(opts: { realmEvents?: boolean }) { let { completed } = this.startReindex(opts); completed diff --git a/packages/runtime-common/tasks/full-reindex.ts b/packages/runtime-common/tasks/full-reindex.ts index 485ca0e345e..2cfa6cf0299 100644 --- a/packages/runtime-common/tasks/full-reindex.ts +++ b/packages/runtime-common/tasks/full-reindex.ts @@ -60,8 +60,13 @@ function chooseFullReindexCoalesceDecision( context: QueueCoalesceContext, ): QueueCoalesceDecision { let { incoming, candidates } = context; + // A sweep merges only with one that makes the same choice about announcing: + // its realms' passes either all announce or all don't, and a merged sweep + // would impose one sweep's choice on the other's realms. let twin = candidates.find( - (candidate) => candidate.jobType === incoming.jobType, + (candidate) => + candidate.jobType === incoming.jobType && + announcesSweep(candidate.args) === announcesSweep(incoming.args), ); if (!twin) { return { type: 'insert' }; @@ -72,20 +77,17 @@ function chooseFullReindexCoalesceDecision( let merged = [ ...new Set([...getRealmUrls(twin.args), ...getRealmUrls(incoming.args)]), ]; - let { realmEvents: _realmEvents, ...args } = { - ...(isObjectLike(twin.args) ? twin.args : {}), - ...(isObjectLike(incoming.args) ? incoming.args : {}), - realmUrls: merged, - } as JSONTypes.Object; - // One sweep that announces is enough for the merged sweep to announce. - let announces = announcesSweep(twin.args) || announcesSweep(incoming.args); return { type: 'join', jobId: twin.id, update: { priority: Math.max(twin.priority, incoming.priority), timeout: Math.max(twin.timeout, incoming.timeout), - args: announces ? args : { ...args, realmEvents: false }, + args: { + ...(isObjectLike(twin.args) ? twin.args : {}), + ...(isObjectLike(incoming.args) ? incoming.args : {}), + realmUrls: merged, + }, }, }; } From 525ddf646e10c08cca86945a4636e95f10cd5276 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 22 Oct 2026 09:35:01 -0400 Subject: [PATCH 3/3] Document boxel file write --no-realm-events in the file-ops skill Co-Authored-By: Claude Opus 5.5 --- packages/boxel-cli/plugin/skills/file-ops/SKILL.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/boxel-cli/plugin/skills/file-ops/SKILL.md b/packages/boxel-cli/plugin/skills/file-ops/SKILL.md index 68c322ad3f1..a030463d247 100644 --- a/packages/boxel-cli/plugin/skills/file-ops/SKILL.md +++ b/packages/boxel-cli/plugin/skills/file-ops/SKILL.md @@ -66,6 +66,7 @@ Write a file to a realm (reads content from STDIN or --file) - `--realm ` — The realm URL to write to - `--file ` — Read content from a local file instead of STDIN - `--realm-secret-seed` — Administrative auth: prompt for a realm secret seed and mint a JWT locally instead of using a Matrix profile (env: BOXEL_REALM_SECRET_SEED) +- `--no-realm-events` — Index the write without announcing it to open tabs, which pick it up on their next load (for deploys) - `--json` — Output raw JSON response ### `boxel file list` @@ -125,5 +126,6 @@ Force realm re-indexing of one or more files by making a semantically-neutral ed ## Pitfalls - `boxel file write` reads content from stdin by default. Pipe in or use `--file` — don't expect interactive prompting. +- `boxel file write --no-realm-events` follows the same rule as `realm push --no-realm-events`: use it for a deploy into a shared realm, never for a write someone with the realm open needs to see. See [Pushing without realm events](../realm-sync/SKILL.md#pushing-without-realm-events---no-realm-events) in `/boxel-cli:realm-sync`. - File paths are realm-relative (e.g., `BlogPost/my-post.json`), not absolute URLs. The realm URL goes in `--realm`. - `delete` is unrecoverable from the CLI. Confirm intent before running, especially against production realms.