Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/realm-server/handlers/handle-publish-realm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,17 @@ export default function handlePublishRealm({
await publishedRealm.fullIndex(userInitiatedPriority, {
clearLastModified: true,
});

// The source realm's `RealmInfo.lastPublishedAt` map is built
// from `realm_registry` rows joined on `source_url = sourceRealmURL`,
// so publishing this derivative just changed it. Without
// invalidating the cache, the source's `getRealmInfo()` keeps
// returning the pre-publish snapshot — and the card+json ETag,
// which folds a hash of that snapshot in, would still match a
// stale `If-None-Match` and serve a 304 with the old
// `meta.realmInfo.lastPublishedAt`. (CS-11010)
sourceRealm.invalidateCachedRealmInfo();

let publishedPermissions = await fetchRealmPermissions(
dbAdapter,
new URL(publishedRealmURL),
Expand Down
18 changes: 18 additions & 0 deletions packages/realm-server/handlers/handle-unpublish-realm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,24 @@ export default function handleUnpublishRealm({
await removeRealmPermissions(dbAdapter, new URL(publishedRealmURL));
});

// Removing this derivative just changed the source realm's
// `RealmInfo.lastPublishedAt` map (rows where `source_url =
// sourceRealmURL`). Without invalidating the source's cached
// realm info, its card+json ETag (which folds a hash of the
// realm info in) would keep matching pre-unpublish If-None-Match
// headers and serve a 304 with stale `meta.realmInfo`. (CS-11010)
let sourceRealmURL = publishedRealmInfo.source_realm_url;
if (sourceRealmURL) {
try {
let sourceRealm = await reconciler.lookupOrMount(sourceRealmURL);
sourceRealm?.invalidateCachedRealmInfo();
} catch (err) {
log.warn(
`Could not invalidate source realm cached realm-info for ${sourceRealmURL} after unpublish: ${err}`,
);
}
}

// Permissions for the published realm were removed inside the
// write lock above, so fetchRealmPermissions(publishedRealmURL)
// would return nothing useful for X-Boxel-Realm-Public-Readable.
Expand Down
183 changes: 183 additions & 0 deletions packages/realm-server/tests/card-endpoints-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -873,6 +873,81 @@ module(basename(__filename), function () {
'deeply nested linksToMany returns first match',
);
});

test('returns an ETag and public cache-control on a 200 response', async function (assert) {
let response = await request
.get('/person-1')
.set('Accept', 'application/vnd.card+json');

assert.strictEqual(response.status, 200, 'HTTP 200 status');
let etag = response.get('etag') ?? '';
assert.ok(etag, 'response carries an ETag');
assert.true(
/^"\d+(?:-[0-9a-f]+)?:card"$/.test(etag),
`ETag matches "<indexed_at>(-<realmInfoHash>)?:card" pattern (got ${etag})`,
);
assert.strictEqual(
response.get('cache-control'),
'public, max-age=0, must-revalidate',
'world-readable realm advertises public cache-control',
);
// The X-Boxel-Etag-Suppressed signal only fires when the
// foreign-deps guard rejects the ETag; a card with purely
// local deps must NOT carry it, otherwise ops dashboards
// can't tell normal from suppressed traffic.
assert.notOk(
response.get('X-Boxel-Etag-Suppressed'),
'no suppression signal on a card with only local deps',
);
});

test('returns 304 when If-None-Match matches the current ETag', async function (assert) {
let firstResponse = await request
.get('/person-1')
.set('Accept', 'application/vnd.card+json');
assert.strictEqual(firstResponse.status, 200, 'first GET succeeds');
let etag = firstResponse.get('etag') ?? '';
assert.ok(etag, 'first response carries an ETag');

let secondResponse = await request
.get('/person-1')
.set('Accept', 'application/vnd.card+json')
.set('If-None-Match', etag);

assert.strictEqual(
secondResponse.status,
304,
'matching If-None-Match short-circuits to 304',
);
assert.strictEqual(
secondResponse.get('etag'),
etag,
'304 response echoes the ETag',
);
assert.strictEqual(
secondResponse.get('cache-control'),
'public, max-age=0, must-revalidate',
'304 response keeps the cache-control directive',
);
// 304 must not have a body — `response.body` may be `{}` when
// supertest can't decode an empty buffer, so check `response.text`.
assert.notOk(secondResponse.text, '304 response has no body');
});

test('returns 200 when If-None-Match does not match', async function (assert) {
let response = await request
.get('/person-1')
.set('Accept', 'application/vnd.card+json')
.set('If-None-Match', '"stale-etag"');

assert.strictEqual(
response.status,
200,
'non-matching If-None-Match falls through to a fresh 200',
);
assert.ok(response.body.data, 'full body is returned');
assert.ok(response.get('etag'), '200 response still carries an ETag');
});
});

module('published realm', function (hooks) {
Expand Down Expand Up @@ -1102,6 +1177,15 @@ module(basename(__filename), function () {
undefined,
'realm is not public readable',
);
assert.strictEqual(
response.get('cache-control'),
'private, max-age=0, must-revalidate',
'auth-gated realm advertises private cache-control so a shared cache cannot serve one user the response of another',
);
assert.ok(
response.get('etag'),
'auth-gated response carries an ETag',
);
});

test('200 when server user assumes user that has read permission', async function (assert) {
Expand Down Expand Up @@ -2410,6 +2494,105 @@ module(basename(__filename), function () {
);
});

test('PATCH response carries an ETag and writes invalidate the previous one', async function (assert) {
// Capture the pre-patch ETag, mutate the card, and verify the PATCH
// response advertises a *different* ETag for the new state — that's
// the contract that lets the caller cache the post-patch body
// without an extra round-trip GET.
let initialResponse = await request
.get('/person-1')
.set('Accept', 'application/vnd.card+json');
let originalEtag = initialResponse.get('etag') ?? '';
assert.ok(originalEtag, 'initial GET returns an ETag');

let patchResponse = await request
.patch('/person-1')
.send({
data: {
type: 'card',
attributes: { firstName: 'Van Gogh' },
meta: {
adoptsFrom: {
module: rri('./person.gts'),
name: 'Person',
},
},
},
})
.set('Accept', 'application/vnd.card+json');

assert.strictEqual(patchResponse.status, 200, 'PATCH succeeds');
let patchEtag = patchResponse.get('etag') ?? '';
assert.ok(patchEtag, 'PATCH response carries an ETag');
assert.true(
/^"\d+(?:-[0-9a-f]+)?:card"$/.test(patchEtag),
`PATCH ETag matches "<indexed_at>(-<realmInfoHash>)?:card" pattern (got ${patchEtag})`,
);
assert.notStrictEqual(
patchEtag,
originalEtag,
'PATCH advances the ETag because indexed_at bumps on the rewrite',
);

// Sending the OLD etag against If-None-Match must NOT short-circuit
// (otherwise we'd serve a stale 304 after a write).
let staleResponse = await request
.get('/person-1')
.set('Accept', 'application/vnd.card+json')
.set('If-None-Match', originalEtag);
assert.strictEqual(
staleResponse.status,
200,
'old ETag no longer matches → fresh 200',
);
assert.strictEqual(
staleResponse.get('etag'),
patchEtag,
'GET reports the new ETag',
);

// And the new etag from the PATCH must short-circuit on next GET.
let cachedResponse = await request
.get('/person-1')
.set('Accept', 'application/vnd.card+json')
.set('If-None-Match', patchEtag);
assert.strictEqual(
cachedResponse.status,
304,
'new ETag from PATCH lets a follow-up GET short-circuit',
);
});

test('no-op PATCH response carries an ETag matching the existing one', async function (assert) {
let initialResponse = await request
.get('/person-1')
.set('Accept', 'application/vnd.card+json');
let initialEtag = initialResponse.get('etag');
assert.ok(initialEtag, 'initial GET returns an ETag');

let patchResponse = await request
.patch('/person-1')
.send({
data: {
type: 'card',
meta: {
adoptsFrom: {
module: rri('./person'),
name: 'Person',
},
},
},
})
.set('Accept', 'application/vnd.card+json');

assert.strictEqual(patchResponse.status, 200, 'no-op PATCH succeeds');
assert.strictEqual(
patchResponse.get('etag'),
initialEtag,
'no-op PATCH returns the same ETag (no rewrite, indexed_at unchanged)',
);
});

test('patches card when index entry is an error using pristine doc', async function (assert) {
let cardURL = `${testRealmHref}person-1`;
let errorDoc = {
Expand Down
62 changes: 61 additions & 1 deletion packages/realm-server/tests/realm-endpoints-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,11 +206,15 @@ module(basename(__filename), function () {
'Authorization',
`Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`,
);
// Card+json is now ETag-cacheable (CS-11010): the realm advertises
// public/private + max-age=0 + must-revalidate so browsers always
// revalidate, but a matching If-None-Match short-circuits to 304.
assert.strictEqual(
response.headers['cache-control'],
'no-store, no-cache, must-revalidate',
'public, max-age=0, must-revalidate',
'cache control header is set correctly',
);
assert.ok(response.headers['etag'], 'ETag header is present');
});

test('serves file meta with dedicated accept header', async function (assert) {
Expand Down Expand Up @@ -635,6 +639,62 @@ module(basename(__filename), function () {
);
});

test('card ETag invalidates after realm config change so old If-None-Match does not 304 stale realmInfo', async function (assert) {
// Capture the pre-PATCH ETag.
let initialResponse = await request
.get('/person-1')
.set('Accept', 'application/vnd.card+json');
assert.strictEqual(initialResponse.status, 200, 'initial GET succeeds');
let initialEtag = initialResponse.headers['etag'];
assert.ok(initialEtag, 'initial response carries an ETag');

// Change the realm name — this nulls the cached realmInfo without
// touching boxel_index, so a naive `indexed_at`-only ETag would
// still match and 304 with stale `meta.realmInfo`. The fix folds
// a hash of the realmInfo into the ETag base.
let patchResponse = await request
.patch('/_config')
.set('Accept', SupportedMimeType.JSON)
.set(
'Authorization',
`Bearer ${createJWT(testRealm, 'user', [
'read',
'write',
'realm-owner',
])}`,
)
.send({
data: {
type: 'realm-config',
attributes: { name: 'Etag Invalidation Test Realm' },
},
});
assert.strictEqual(patchResponse.status, 200, 'config patch succeeded');

// Replay the GET with the OLD ETag. It must NOT 304: the assembled
// body now has a different `meta.realmInfo.name`, so the validator
// has to recognize that as a content change.
let revalidationResponse = await request
.get('/person-1')
.set('Accept', 'application/vnd.card+json')
.set('If-None-Match', initialEtag);
assert.strictEqual(
revalidationResponse.status,
200,
'old ETag does not match after /_config PATCH; server returns full 200',
);
assert.notStrictEqual(
revalidationResponse.headers['etag'],
initialEtag,
'fresh response carries a different ETag',
);
assert.strictEqual(
revalidationResponse.body.data.meta.realmInfo.name,
'Etag Invalidation Test Realm',
'fresh response reflects the post-PATCH realm name',
);
});

test('returns bad request for invalid json body', async function (assert) {
let response = await request
.patch('/_config')
Expand Down
18 changes: 17 additions & 1 deletion packages/runtime-common/realm-index-query-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,17 @@ type SearchResult = SearchResultDoc | SearchResultError;
interface SearchResultDoc {
type: 'doc';
doc: SingleCardDocument;
// indexed_at on the primary card's index row. Bumps on every reindex
// (direct file write OR dependency-triggered re-write), so it's a
// complete fingerprint for the assembled card+json document and is
// used as the ETag base by the realm's GET/PATCH handlers.
indexedAt: number | null;
// deps array on the primary card's index row. Used by the realm's
// GET/PATCH handlers to detect foreign-realm dependencies — when
// present, ETag emission is suppressed because cross-realm
// invalidation does not cascade indexed_at (see
// `index-writer.ts.calculateInvalidations` realm_url filter).
deps: string[] | null;
}

export interface SearchResultError {
Expand Down Expand Up @@ -456,7 +467,12 @@ export class RealmIndexQueryEngine {
}
relativizeDocument(doc, this.realmURL);
await this.attachRealmInfo(doc);
return { type: 'doc', doc };
return {
type: 'doc',
doc,
indexedAt: instance.indexedAt,
deps: instance.deps,
};
}

async instance(
Expand Down
Loading
Loading