diff --git a/packages/host/app/commands/publish-realm.ts b/packages/host/app/commands/publish-realm.ts index f78f3a6fc4..007f6bdceb 100644 --- a/packages/host/app/commands/publish-realm.ts +++ b/packages/host/app/commands/publish-realm.ts @@ -17,11 +17,12 @@ import type RealmService from '../services/realm'; import type { PublishabilityViolation } from '../services/realm'; // Publishes a realm to one or more destinations (subdirectory Boxel Spaces or -// custom domains). The command resolves once the realm-server accepts each -// publish request and reports per-target status. Indexed-and-viewable -// readiness is not awaited here: realm `index` events aren't delivered to the -// run-command/prerender context, so a caller that needs the published realm -// ready polls its `_readiness-check` over HTTP instead. +// custom domains) and reports per-target status. `_publish-realm` returns 202 +// before the published realm is indexed, so `realm.publish` polls each +// target's `_readiness-check` over HTTP before resolving — the command +// thus completes only once every published realm is indexed and viewable. +// (Readiness is polled over HTTP rather than awaited via realm `index` events, +// which aren't delivered to the run-command/prerender context.) export default class PublishRealmCommand extends HostBaseCommand< typeof BaseCommandModule.PublishRealmInput, typeof BaseCommandModule.PublishRealmResult diff --git a/packages/host/app/services/realm-server.ts b/packages/host/app/services/realm-server.ts index 4ca9d4d599..af70e88be0 100644 --- a/packages/host/app/services/realm-server.ts +++ b/packages/host/app/services/realm-server.ts @@ -21,6 +21,7 @@ import { ri, testRealmURL, unpublishRealm as unpublishRealmOperation, + waitForReady as waitForReadyOperation, type RealmClient, type RealmIdentifier, type RealmInfo, @@ -944,6 +945,22 @@ export default class RealmServerService extends Service { return unpublishRealmOperation(this.realmClient, { publishedRealmURL }); } + // Polls _readiness-check until the published realm is + // indexed and viewable. `_publish-realm` returns 202 before indexing + // finishes, so callers that need the realm ready wait here — the Publish UI + // keeps its "Publishing…" state until this resolves. + async waitForRealmReady( + publishedRealmURL: string, + opts?: { timeoutMs?: number; pollIntervalMs?: number }, + ) { + await this.login(); + return waitForReadyOperation(this.realmClient, { + publishedRealmURL, + timeoutMs: opts?.timeoutMs, + pollIntervalMs: opts?.pollIntervalMs, + }); + } + async checkDomainAvailability( subdomain: string, ): Promise { diff --git a/packages/host/app/services/realm.ts b/packages/host/app/services/realm.ts index 50d5ed3494..69a4027b21 100644 --- a/packages/host/app/services/realm.ts +++ b/packages/host/app/services/realm.ts @@ -627,7 +627,14 @@ class RealmResource { this._publishingRealms.push(url); try { - return await this.realmServer.publishRealm(this.url, url); + let result = await this.realmServer.publishRealm(this.url, url); + // `_publish-realm` returns 202 before the published realm is + // indexed. Keep the "Publishing…" state until the realm passes its + // readiness check so "Open Site" only enables once the page is + // actually viewable. Poll the URL the server actually published to — + // a server-side domain override can make that differ from `url`. + await this.realmServer.waitForRealmReady(result.publishedRealmURL); + return result; } catch (error) { console.error(`Error publishing to URL ${url}:`, error); throw error; // Re-throw so Promise.allSettled can capture it as rejected diff --git a/packages/host/tests/acceptance/host-submode-test.gts b/packages/host/tests/acceptance/host-submode-test.gts index 45cbeea60d..e9aa741119 100644 --- a/packages/host/tests/acceptance/host-submode-test.gts +++ b/packages/host/tests/acceptance/host-submode-test.gts @@ -688,6 +688,9 @@ module('Acceptance | host submode', function (hooks) { getService('realm-server').publishRealm = publishRealm; getService('realm-server').unpublishRealm = unpublishRealm; + // realm.publish polls readiness after the 202; these tests drive + // publish timing via publishDeferred, so report ready instantly. + getService('realm-server').waitForRealmReady = async () => {}; // The publish modal asks the server for the unlisted-link slug on open; // default it so the unlisted card renders a URL (not a stuck "Generating // link…") in tests that don't exercise it. Tests that do use diff --git a/packages/host/tests/integration/commands/publish-realm-test.gts b/packages/host/tests/integration/commands/publish-realm-test.gts index 70b5b3294a..e139164d9b 100644 --- a/packages/host/tests/integration/commands/publish-realm-test.gts +++ b/packages/host/tests/integration/commands/publish-realm-test.gts @@ -97,6 +97,18 @@ module('Integration | commands | publish-realm', function (hooks) { ); }, }, + // `realm.publish` polls each target's `_readiness-check` after the 202. + // The endpoint matcher keys on pathname, so cover the root-domain targets + // (`/_readiness-check`) and the subdirectory target (`/my-space/...`). + // Report ready immediately so the command resolves. + { + route: '_readiness-check', + getResponse: async () => new Response(null, { status: 200 }), + }, + { + route: 'my-space/_readiness-check', + getResponse: async () => new Response(null, { status: 200 }), + }, ]); setupRealmCacheTeardown(hooks); diff --git a/packages/matrix/tests/publish-realm.spec.ts b/packages/matrix/tests/publish-realm.spec.ts index 2f29251f66..e2a3f46dbb 100644 --- a/packages/matrix/tests/publish-realm.spec.ts +++ b/packages/matrix/tests/publish-realm.spec.ts @@ -280,6 +280,9 @@ test.describe('Publish realm', () => { page, request, }) => { + // Two full publish+index cycles plus a readiness wait — give this E2E + // flow more than the default per-attempt budget. + test.setTimeout(120_000); // CS-11043 regression net. The bug was: a republish reported success // server-side but the published URL kept serving the previous publish's // rendered HTML, sometimes for tens of hours. Every existing @@ -463,15 +466,24 @@ test.describe('Publish realm', () => { .click(); let secondTab = await secondTabPromise; await secondTab.waitForLoadState(); - // Generous retry budget: if waitForResponse above was downgraded - // to null, the publish may not yet be done by the time we land on - // the published URL. The assertion retries until the sentinel - // appears or this budget expires, which gives slow republishes - // room to land without flapping the test. - await expect(secondTab.locator('[data-test-sentinel-output]')).toHaveText( - updatedSentinel, - { timeout: 120_000 }, - ); + // The publish handler returns 202 before the reindex finishes, and a tab + // that loaded before it landed won't re-fetch on its own — so reload until + // the published URL serves the updated sentinel. The retry budget gives the + // background reindex room to settle. + await expect + .poll( + async () => { + await secondTab.reload({ waitUntil: 'domcontentloaded' }); + return ( + (await secondTab + .locator('[data-test-sentinel-output]') + .textContent() + .catch(() => null)) ?? '' + ); + }, + { timeout: 60_000, intervals: [2_000] }, + ) + .toBe(updatedSentinel); await expect(secondTab.locator('body')).not.toContainText(initialSentinel); await secondTab.close(); await page.bringToFront(); diff --git a/packages/realm-server/handlers/handle-publish-realm.ts b/packages/realm-server/handlers/handle-publish-realm.ts index 8be8959723..fecf484a57 100644 --- a/packages/realm-server/handlers/handle-publish-realm.ts +++ b/packages/realm-server/handlers/handle-publish-realm.ts @@ -1,6 +1,5 @@ import type Koa from 'koa'; import { - createResponse, fetchUserPermissions, isResolvedCodeRef, query, @@ -21,14 +20,10 @@ import { getPublishedRealmDomainOverrides } from '@cardstack/runtime-common/cons import { join } from 'path'; import fsExtra from 'fs-extra'; -const { - copySync, - readJsonSync, - writeJsonSync, - removeSync, - existsSync, - moveSync, -} = fsExtra; +// Async fs ops only: the publish handler runs these inside the request, and a +// synchronous copy/move of a whole realm directory would freeze the Node event +// loop, stalling every other concurrent request until it finished. +const { copy, readJson, writeJson, remove, pathExists, move } = fsExtra; import { fetchRequestFromContext, @@ -154,15 +149,20 @@ async function maybeApplyPublishedRealmOverride( // the index.json's adoptsFrom — a published realm that has customised // its index to a different CardDef is left alone (its isolated render // is presumably the bespoke landing page the publisher wanted). -function ensureRealmIndexBoilerplateOptIn(publishedRealmPath: string): void { +async function ensureRealmIndexBoilerplateOptIn( + publishedRealmPath: string, +): Promise { let indexJsonPath = join(publishedRealmPath, 'index.json'); let realmJsonPath = join(publishedRealmPath, 'realm.json'); - if (!existsSync(indexJsonPath) || !existsSync(realmJsonPath)) { + if ( + !(await pathExists(indexJsonPath)) || + !(await pathExists(realmJsonPath)) + ) { return; } let indexDoc: unknown; try { - indexDoc = readJsonSync(indexJsonPath); + indexDoc = await readJson(indexJsonPath); } catch (e) { log.warn( `could not parse published index.json at ${indexJsonPath}: ${ @@ -186,7 +186,7 @@ function ensureRealmIndexBoilerplateOptIn(publishedRealmPath: string): void { } let realmConfigDoc: Record; try { - realmConfigDoc = readJsonSync(realmJsonPath) as Record; + realmConfigDoc = (await readJson(realmJsonPath)) as Record; } catch (e) { log.warn( `could not parse published realm.json at ${realmJsonPath}: ${ @@ -204,7 +204,7 @@ function ensureRealmIndexBoilerplateOptIn(publishedRealmPath: string): void { data.attributes = attributes; realmConfigDoc.data = data; try { - writeJsonSync(realmJsonPath, realmConfigDoc, { spaces: 2 }); + await writeJson(realmJsonPath, realmConfigDoc, { spaces: 2 }); } catch (e) { log.warn( `could not write includePrerenderedDefaultRealmIndex into ${realmJsonPath}: ${ @@ -420,9 +420,8 @@ export default function handlePublishRealm({ // mounts the (re-)published realm on its first request. The // response is 202 Accepted with status:'pending'; the client polls // //_readiness-check to learn when it's ready. - let { lastPublishedAt, publishedRealmId } = await dbAdapter.withWriteLock( - publishedRealmURL, - async () => { + let { lastPublishedAt, publishedRealmId, isNewRealm } = + await dbAdapter.withWriteLock(publishedRealmURL, async () => { let existingRows = (await query(dbAdapter, [ `SELECT disk_id, owner_username FROM realm_registry WHERE kind = 'published' AND url =`, param(publishedRealmURL), @@ -482,21 +481,24 @@ export default function handlePublishRealm({ // enqueueReindexRealmJob below to refresh the index. let tempCopyPath = `${publishedRealmPath}.tmp`; let backupPath = `${publishedRealmPath}.backup`; - removeSync(tempCopyPath); - removeSync(backupPath); - copySync(sourceRealmPath, tempCopyPath); + await remove(tempCopyPath); + await remove(backupPath); + await copy(sourceRealmPath, tempCopyPath); try { - if (existsSync(publishedRealmPath)) { - moveSync(publishedRealmPath, backupPath); + if (await pathExists(publishedRealmPath)) { + await move(publishedRealmPath, backupPath); } - moveSync(tempCopyPath, publishedRealmPath); - removeSync(backupPath); + await move(tempCopyPath, publishedRealmPath); + await remove(backupPath); } catch (swapError) { // Restore the old published realm if the swap failed - if (!existsSync(publishedRealmPath) && existsSync(backupPath)) { - moveSync(backupPath, publishedRealmPath); + if ( + !(await pathExists(publishedRealmPath)) && + (await pathExists(backupPath)) + ) { + await move(backupPath, publishedRealmPath); } - removeSync(tempCopyPath); + await remove(tempCopyPath); throw swapError; } @@ -524,7 +526,7 @@ export default function handlePublishRealm({ // The flag is written to the published realm's RealmConfig // card (/realm.json) on disk before the reindex below picks // it up. - ensureRealmIndexBoilerplateOptIn(publishedRealmPath); + await ensureRealmIndexBoilerplateOptIn(publishedRealmPath); // Clear stale modules cache for the published realm (including // error entries from a previous publish) before the reindex's @@ -554,7 +556,7 @@ export default function handlePublishRealm({ // Phase 3 PR 2 rollback simplification: no in-memory // realms[]/virtualNetwork state to unwind. Just remove the // FS swap that we just put in place. - removeSync(publishedRealmPath); + await remove(publishedRealmPath); throw dbError; } @@ -573,12 +575,13 @@ export default function handlePublishRealm({ // an async race against the immediately-enqueued reindex. // Force the invalidation synchronously here. // - // For a new publish, lookupOrMount mounts the realm fresh - // (registry row was just upserted above); the cache is - // empty so clearLocalSourceCaches is a no-op. Either way the - // reindex below sees correct source. + // Use the non-mounting `mounted` map rather than lookupOrMount: + // for a new publish the realm isn't mounted here yet and there's + // nothing cached to clear — and mounting it would await a + // from-scratch index inside the request, which this handler must + // not block on. It lazy-mounts fresh on its first request instead. let mountedRealmForCacheClear = - await reconciler.lookupOrMount(publishedRealmURL); + reconciler.mounted.get(publishedRealmURL); if (mountedRealmForCacheClear) { // Sync local clear + cross-replica NOTIFY in one call. The // local clear is what this replica's reindex fan-out needs; @@ -587,15 +590,16 @@ export default function handlePublishRealm({ await mountedRealmForCacheClear.clearLocalSourceCachesAndBroadcast(); } - // Refresh the index. For a new publish this is redundant - // (lazy-mount's first start() does its own fullIndex on a - // fresh DB), but the from-scratch-index coalesce handler - // (CS-10893) collapses both into a single canonical job. For - // a republish where the realm is already mounted with a - // resolved #startedUp, this is the only mechanism that - // re-indexes against the swapped files. clearLastModified - // forces every row to re-render even if mtimes appear - // unchanged (file copies preserve mtimes). + // Durability enqueue: guarantees the swapped files get indexed + // even if no client ever polls this published realm. The index is + // not awaited here — the handler returns 202 (pending) and the + // client polls _readiness-check. For a realm not mounted on this + // instance, its first request (typically the readiness poll) + // lazy-mounts it and start()'s from-scratch pass coalesces with + // this job. For a republish already mounted here, the post-lock + // fullIndex below tracks completion for readiness. clearLastModified + // forces every row to re-render even though file copies preserve + // mtimes. await enqueueReindexRealmJob( publishedRealmURL, realmUsername, @@ -605,48 +609,61 @@ export default function handlePublishRealm({ { clearLastModified: true }, ); - return { lastPublishedAt, publishedRealmId }; - }, - ); - - // Mount + start the published realm on this instance now. The - // reconciler's prepareRealmFromRow constructs a Realm and adds - // it to realms[] / virtualNetwork; ensureMounted then awaits - // realm.start() which awaits the from-scratch-index job we - // enqueued above (the chooseFromScratch coalesce JOINs the - // start()-enqueued job with ours). By the time we return 202, - // indexing is complete on this instance — sibling instances - // pick the published realm up via NOTIFY and lazy-mount on - // first request. This preserves the test-suite's synchronous- - // publish semantics while keeping the handler purely registry- - // driven. - let publishedRealm = await reconciler.lookupOrMount(publishedRealmURL); - if (!publishedRealm) { - throw new Error( - `expected published realm ${publishedRealmURL} to be mounted after publish — registry row missing or mount failed`, - ); + return { lastPublishedAt, publishedRealmId, isNewRealm }; + }); + + // Mount the published realm on this instance so it is served as soon as + // the 202 returns, but do NOT await its index/prerender — that runs in + // the background and clients poll _readiness-check. + // ensureMounted publishes the realm into virtualNetwork synchronously, so + // a request arriving right after this 202 (the readiness poll, or a + // visitor) resolves to the realm rather than 404ing; awaiting the full + // index + prerender (pool-bound) instead would hold the HTTP request open + // for the entire indexing duration. Sibling instances pick the realm up + // via the realm_registry NOTIFY and lazy-mount on their first request. + if (isNewRealm) { + // Brand-new publish: no prior index. lookupOrMount's start() runs a + // from-scratch index (isNewIndex), and #startedUp resolves only after + // it completes — readinessCheck awaits #startedUp, so a single pass + // gates readiness. Don't await it here (that would block the response + // on the full index); the durability enqueue above coalesces with it. + void reconciler + .lookupOrMount(publishedRealmURL) + .catch((err: unknown) => { + log.error( + `background mount failed for ${publishedRealmURL}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + }); + } else { + // Republish: the realm already has index rows, so start() does NOT + // re-index them and #startedUp resolves without reflecting the swapped + // files — readinessCheck must instead wait on indexing(). Register a + // tracked clearLastModified reindex SYNCHRONOUSLY (before the 202) so + // indexing() reflects it and readiness can't report ready before the + // reindex lands. Get the mounted realm, or mount it first when this + // instance is cold (e.g. after a restart, or the publish landed on an + // instance that never mounted this realm) — that mount is fast because + // start() skips indexing for an existing index. fullIndex invalidates + // the cached RealmInfo before the pass, so og:title re-bakes from the + // swapped realm.json (parseRealmInfo's disk overlay) in a single pass. + // The reindex job coalesces with the durability enqueue above. + let publishedRealm = + reconciler.mounted.get(publishedRealmURL) ?? + (await reconciler.lookupOrMount(publishedRealmURL)); + if (publishedRealm) { + void publishedRealm + .fullIndex(userInitiatedPriority, { clearLastModified: true }) + .catch((err: unknown) => { + log.error( + `background publish reindex failed for ${publishedRealmURL}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + }); + } } - // Re-run a full index after start()'s pass so the RealmConfig card - // at /realm.json is queryable by parseRealmInfo before /index is - // re-rendered. start()'s from-scratch pass walks files in order and - // typically renders /index before /realm.json — at which point - // attachRealmInfo → getRealmInfo → parseRealmInfo finds /realm.json - // not yet indexed, falls back to "Unnamed Workspace", and caches - // that. The prerendered head HTML for /index is baked with the - // stale value, surfacing as og:title="Unnamed Workspace" on the - // published page. - // - // clearLastModified: true forces every row to re-render on this - // pass even though copySync preserves mtimes — without it, the - // indexer's mtime-cache check would skip the already-rendered - // /index and the stale prerendered HTML would persist. - // Realm.fullIndex clears #cachedRealmInfo before this pass so the - // first attachRealmInfo call re-reads parseRealmInfo against the - // now-populated index and bakes the correct realm name into the - // re-rendered prerendered HTML. - await publishedRealm.fullIndex(userInitiatedPriority, { - clearLastModified: true, - }); // The source realm's `RealmInfo.lastPublishedAt` map is built // from `realm_registry` rows joined on `source_url = sourceRealmURL`, @@ -663,8 +680,19 @@ export default function handlePublishRealm({ new URL(publishedRealmURL), ); - let response = createResponse({ - body: JSON.stringify( + // Build the 202 directly rather than via createResponse: the published + // realm may not be mounted on this instance (a new publish lazy-mounts + // on first request), so there is no Realm object to read a url from. + // + // Point clients at the status monitor for this accepted-but-not-yet- + // -complete request (RFC 9110 §15.3.3): `Location` is the published + // realm's readiness check, which resolves once it is indexed and + // viewable, and `Retry-After` hints the poll interval. This lets a + // consumer discover where to wait for completion from the response + // itself rather than hard-coding the readiness URL. + let readinessCheckURL = `${publishedRealmURL}_readiness-check`; + let response = new Response( + JSON.stringify( { data: { type: 'published_realm', @@ -680,17 +708,19 @@ export default function handlePublishRealm({ null, 2, ), - init: { + { status: 202, headers: { 'content-type': SupportedMimeType.JSONAPI, + Location: readinessCheckURL, + 'Retry-After': '1', + 'X-Boxel-Realm-Url': publishedRealmURL, + ...(publishedPermissions['*']?.includes('read') && { + 'X-Boxel-Realm-Public-Readable': 'true', + }), }, }, - requestContext: { - realm: publishedRealm, - permissions: publishedPermissions, - }, - }); + ); await setContextResponse(ctxt, response); return; } catch (error: any) { diff --git a/packages/realm-server/server.ts b/packages/realm-server/server.ts index a04d87f442..d0619e30b8 100644 --- a/packages/realm-server/server.ts +++ b/packages/realm-server/server.ts @@ -1106,8 +1106,10 @@ export class RealmServer { // prerender tab, or any in-DevTools fetch) get a response // whose `headers.get('ETag')` is `null` even though the // server emitted one — making the entire revalidation - // protocol invisible to JS. - exposeHeaders: 'ETag', + // protocol invisible to JS. Location/Retry-After are likewise + // non-safelisted; expose them so a cross-origin client can read + // the async-publish status monitor target off the 202 response. + exposeHeaders: 'ETag, Location, Retry-After', allowMethods: 'GET,HEAD,PUT,POST,DELETE,PATCH,OPTIONS,QUERY', // Cache the preflight response for 24 h. Without this @koa/cors // omits Access-Control-Max-Age and Chrome falls back to its diff --git a/packages/realm-server/tests/publish-unpublish-realm-test.ts b/packages/realm-server/tests/publish-unpublish-realm-test.ts index 176332cfaf..cbbe7f8e92 100644 --- a/packages/realm-server/tests/publish-unpublish-realm-test.ts +++ b/packages/realm-server/tests/publish-unpublish-realm-test.ts @@ -254,6 +254,15 @@ module(basename(import.meta.filename), function () { 'pending', 'status is pending — client should poll _readiness-check', ); + assert.strictEqual( + response.headers['location'], + `${response.body.data.attributes.publishedRealmURL}_readiness-check`, + 'Location points at the readiness-check status monitor for the 202', + ); + assert.ok( + response.headers['retry-after'], + 'Retry-After hints the readiness poll interval', + ); // Phase 3: publish only writes registry + NOTIFY + enqueues // an indexing job. Drive a reconcile pass to mount the new @@ -1248,6 +1257,144 @@ module(basename(import.meta.filename), function () { ); }); + // CS-11362. A republish that lands on an instance which does not have + // the realm mounted — after a restart, or when the load balancer routes + // the publish to a cold replica — must still gate _readiness-check on the + // reindex of the swapped files. The realm already has index rows, so + // Realm.#startup skips a from-scratch index and #startedUp resolves + // immediately; the durability reindex job is not in this Realm's + // indexing() deferreds. So readiness could return 200 before the + // clearLastModified reindex lands, letting the host open stale content. + // Unlike the test above (which waits on boxel_index and would pass even + // with that bug, since the durability job is eventually consistent), this + // asserts the gating invariant: once readiness reports ready, the + // published index already reflects the updated source. + test('a cold republish does not report ready before the swapped files are reindexed (CS-11362)', async function (assert) { + let sourceRealmURL = new URL(sourceRealmUrlString); + let sourceRealmFsPath = join( + dir.name, + 'realm_server_3', + ...sourceRealmURL.pathname.split('/').filter(Boolean), + ); + let publishedRealmURL = 'http://testuser.localhost:4445/test-realm/'; + let publishedRealmHost = new URL(publishedRealmURL).host; + let publishedRealmPath = new URL(publishedRealmURL).pathname; + let cardFilename = 'sentinel-card.json'; + let initialName = `sentinel-initial-${uuidv4()}`; + let updatedName = `sentinel-updated-${uuidv4()}`; + let buildCardJson = (name: string) => ({ + data: { + type: 'card', + id: `${sourceRealmUrlString}sentinel-card`, + attributes: { cardInfo: { name } }, + meta: { + adoptsFrom: { + module: '@cardstack/base/card-api', + name: 'CardDef', + }, + }, + }, + }); + let auth = `Bearer ${createRealmServerJWT( + { user: ownerUserId, sessionRoom: 'session-room-test' }, + realmSecretSeed, + )}`; + let publishBody = JSON.stringify({ + sourceRealmURL: sourceRealmUrlString, + publishedRealmURL, + }); + let publishedSearchDocMatches = async (name: string) => + ( + await dbAdapter.execute( + `SELECT 1 FROM boxel_index + WHERE realm_url = $1 + AND type = 'instance' + AND search_doc::text LIKE '%' || $2 || '%' + LIMIT 1`, + { bind: [publishedRealmURL, name] }, + ) + ).length > 0; + + // First publish with the initial sentinel; wait for it to be indexed. + writeJsonSync( + join(sourceRealmFsPath, cardFilename), + buildCardJson(initialName), + ); + let firstResponse = await request + .post('/_publish-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', auth) + .send(publishBody); + assert.strictEqual(firstResponse.status, 202, 'first publish accepted'); + await testRealmServer.testingOnlyReconcile(); + await waitUntil( + async () => + (await publishedSearchDocMatches(initialName)) ? true : undefined, + { + timeout: 30_000, + interval: 100, + timeoutMessage: + 'initial sentinel never indexed for published realm', + }, + ); + + // Simulate a cold instance: fully drop the published realm from this + // process's in-memory view — realms[], reconciler.mounted, AND the + // virtualNetwork handle — leaving only the registry row + boxel_index, + // the post-restart / cold-target state in which the republish handler + // must mount-and-reindex. Unmounting from virtualNetwork matters: the + // readiness check is routed through virtualNetwork.handle, so a stale + // handle would answer it from the old, already-started Realm and the + // gating this test asserts would be bypassed. + let mountedPublishedRealm = testRealmServer.testingOnlyRealms.find( + (realm) => realm.url === publishedRealmURL, + ); + if (mountedPublishedRealm) { + virtualNetwork.unmount(mountedPublishedRealm.handle); + } + testRealmServer.testingOnlyEvictRealmFromRealmsList(publishedRealmURL); + + // Republish with changed source content. + writeJsonSync( + join(sourceRealmFsPath, cardFilename), + buildCardJson(updatedName), + ); + let secondResponse = await request + .post('/_publish-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', auth) + .send(publishBody); + assert.strictEqual( + secondResponse.status, + 202, + 'cold republish accepted', + ); + + // _readiness-check must block on the in-flight reindex, so by the time + // it returns 200 the published index already reflects the update. (The + // route only matches the RealmInfo mime, so set Accept accordingly.) + let readinessResponse = await request + .get(`${publishedRealmPath}_readiness-check`) + .set('Host', publishedRealmHost) + .set('Accept', 'application/vnd.api+json'); + assert.strictEqual( + readinessResponse.status, + 200, + 'readiness check reports ready', + ); + + assert.true( + await publishedSearchDocMatches(updatedName), + 'published index reflects the updated sentinel once readiness reports ready', + ); + assert.false( + await publishedSearchDocMatches(initialName), + 'published index no longer references the initial sentinel once readiness reports ready', + ); + }); + test('POST /_unpublish-realm can unpublish realm successfully', async function (assert) { // First publish a realm let publishResponse = await request @@ -1272,6 +1419,26 @@ module(basename(import.meta.filename), function () { let publishedRealmURL = publishResponse.body.data.attributes.publishedRealmURL; + // Publish returns 202 before indexing finishes: drive a reconcile + // pass to mount the published realm, then wait for the from-scratch + // index to populate boxel_index before asserting on it. + await testRealmServer.testingOnlyReconcile(); + await waitUntil( + async () => { + let rows = await dbAdapter.execute( + `SELECT 1 FROM boxel_index WHERE realm_url = $1 LIMIT 1`, + { bind: [publishedRealmURL] }, + ); + return rows.length > 0 ? rows : undefined; + }, + { + timeout: 30_000, + interval: 100, + timeoutMessage: + 'boxel_index entries for published realm did not appear', + }, + ); + // Verify that boxel_index entries exist before unpublishing let indexResultsBefore = await dbAdapter.execute( `SELECT * FROM boxel_index WHERE realm_url = '${publishedRealmURL}'`, diff --git a/packages/realm-server/tests/server-endpoints/delete-realm-test.ts b/packages/realm-server/tests/server-endpoints/delete-realm-test.ts index 3e4e9791f6..44fecf1a25 100644 --- a/packages/realm-server/tests/server-endpoints/delete-realm-test.ts +++ b/packages/realm-server/tests/server-endpoints/delete-realm-test.ts @@ -14,7 +14,12 @@ import { query, } from '@cardstack/runtime-common'; -import { insertJob, insertUser, realmSecretSeed } from '../helpers/index.ts'; +import { + insertJob, + insertUser, + realmSecretSeed, + waitUntil, +} from '../helpers/index.ts'; import { createJWT as createRealmServerJWT } from '../../utils/jwt.ts'; import { setupServerEndpointsTest } from './helpers.ts'; @@ -144,6 +149,25 @@ module(`server-endpoints/${basename(import.meta.filename)}`, function (hooks) { // observe its post-DELETE unmount. await context.testRealmServer.testingOnlyReconcile(); + // Publish returns 202 before indexing finishes, and the mount kicks the + // published realm's index in the background — its realm_versions rows are + // written when that index completes. Wait for them before asserting they + // exist below. + await waitUntil( + async () => { + let rows = await context.dbAdapter.execute( + `SELECT 1 FROM realm_versions WHERE realm_url = '${publishedRealmURL}' LIMIT 1`, + ); + return rows.length > 0 ? rows : undefined; + }, + { + timeout: 30_000, + interval: 100, + timeoutMessage: + 'published realm_versions rows did not appear after publish', + }, + ); + let sourceIndexURL = `${realmURL}cleanup-${uuidv4()}.json`; let publishedIndexURL = `${publishedRealmURL}cleanup-${uuidv4()}.json`; let unrelatedIndexURL = `${unrelatedRealmURL}cleanup-${uuidv4()}.json`; diff --git a/packages/realm-server/tests/server-endpoints/index-responses-test.ts b/packages/realm-server/tests/server-endpoints/index-responses-test.ts index 0b152664cd..c9f5007911 100644 --- a/packages/realm-server/tests/server-endpoints/index-responses-test.ts +++ b/packages/realm-server/tests/server-endpoints/index-responses-test.ts @@ -1347,6 +1347,9 @@ module(`server-endpoints/${basename(import.meta.filename)}`, function () { 'Published realm: theme icon links after _publish-realm', function (hooks) { let testRealmHttpServer: Server; + let testRealmServer: Awaited< + ReturnType + >['testRealmServer']; let request: SuperTest; let dbAdapter: PgAdapter; let dir: DirResult; @@ -1365,7 +1368,7 @@ module(`server-endpoints/${basename(import.meta.filename)}`, function () { let virtualNetwork = createVirtualNetwork(); let testRealmDir = join(dir.name, 'realm_server_theme', 'test'); ensureDirSync(testRealmDir); - ({ testRealmHttpServer } = await runTestRealmServer({ + ({ testRealmHttpServer, testRealmServer } = await runTestRealmServer({ virtualNetwork, testRealmDir, fileSystem: {}, @@ -1517,6 +1520,22 @@ module(`server-endpoints/${basename(import.meta.filename)}`, function () { `Failed to publish realm: ${publishResponse.status} ${publishResponse.text}`, ); } + + // `_publish-realm` returns 202 before indexing finishes. Drive a + // reconcile pass to mount the published realm, then hit its readiness + // check, which awaits start() + any in-flight index — so this blocks + // until the swapped files are indexed and the assertions below query + // indexed content. + await testRealmServer.testingOnlyReconcile(); + let readinessResponse = await request + .get(`${publishedRealmPath}_readiness-check`) + .set('Host', publishedRealmHost) + .set('Accept', 'application/vnd.api+json'); + if (readinessResponse.status !== 200) { + throw new Error( + `Published realm not ready: ${readinessResponse.status} ${readinessResponse.text}`, + ); + } }, afterEach: async () => { await closeServer(testRealmHttpServer); diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index ac00b87f9f..661833401a 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -1140,6 +1140,16 @@ export class Realm { requestContext: RequestContext, ) { await this.#startedUp.promise; + // #startedUp is a one-time gate that resolves after the first start()'s + // from-scratch index. On a republish the realm is already mounted with a + // resolved #startedUp, so awaiting it alone would report ready before the + // reindex of the swapped files completes. Also await any in-flight full or + // incremental index so a publish poll only succeeds once the just-published + // content is indexed and viewable. + let inflight = this.indexing(); + if (inflight) { + await inflight; + } return createResponse({ body: null,