Skip to content

Commit 359c0aa

Browse files
os-zhuangclaude
andauthored
fix(objectql,rest): single-item meta reads must revalidate (no max-age=3600) (#2264)
GET /api/v1/meta/object/:name served `Cache-Control: public, max-age, max-age=3600`. A 1h TTL let browsers/CDNs serve a stale object schema without revalidating (the AI-build "New" form kept rendering pre-publish fields), and the directive list collided with the appended max-age into a malformed triple. getMetaItemCached now returns directives ['private','no-cache'] with no maxAge, so the ETag validator (already correct on publish) gates freshness — a cheap 304 when unchanged, fresh data the moment a publish bumps the ETag. The REST header builder strips the bare max-age placeholder before appending the real value, so a maxAge emits once as a well-formed max-age=N. 'private' keeps per-tenant metadata out of shared caches. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a71692e commit 359c0aa

5 files changed

Lines changed: 128 additions & 7 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/objectql": patch
3+
"@objectstack/rest": patch
4+
---
5+
6+
fix(objectql,rest): single-item meta reads must revalidate (no `max-age=3600`)
7+
8+
`GET /api/v1/meta/object/:name` (and the other single-item meta reads served by
9+
the cached path) sent `Cache-Control: public, max-age, max-age=3600`. Two bugs:
10+
11+
1. **Stale metadata for up to an hour.** Object metadata is invalidated by
12+
publish, but a one-hour TTL let browsers (and any CDN/proxy) serve a stale
13+
schema *without revalidating* — e.g. the AI-build "New" create form kept
14+
rendering pre-publish fields until the TTL lapsed. The list endpoint
15+
`GET /api/v1/meta/object` is uncached, which is why list views updated but
16+
single-object reads didn't. `getMetaItemCached` now returns
17+
`directives: ['private', 'no-cache']` with no `maxAge`, so the ETag validator
18+
(which already changes on publish) gates freshness: a cheap `304` when
19+
unchanged, fresh fields the instant a publish bumps the ETag. `private` also
20+
keeps per-tenant metadata out of shared caches.
21+
22+
2. **Malformed header.** The directives array carried a bare `max-age`
23+
placeholder *and* the REST layer appended `max-age=3600` from the `maxAge`
24+
field, concatenating into `public, max-age, max-age=3600`. The header builder
25+
now strips the bare `max-age` token before appending the real value, so a
26+
`maxAge` is emitted once as a well-formed `max-age=N`.

packages/objectql/src/protocol-meta.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,19 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
793793
});
794794
expect(second.notModified).toBe(true);
795795
});
796+
797+
it('returns a revalidate-always cache-control with no max-age TTL', async () => {
798+
// Metadata is invalidated by publish, so freshness must be gated by
799+
// the ETag validator — not a TTL. A `max-age` here pinned a stale
800+
// schema (e.g. the AI-build "New" form showing pre-publish fields)
801+
// for up to an hour with no revalidation in between. `private` also
802+
// keeps per-tenant metadata out of shared CDN/proxy caches.
803+
const res = await protocol.getMetaItemCached({ type: 'object', name: 'customer' });
804+
expect(res.notModified).toBe(false);
805+
expect(res.cacheControl?.directives).toEqual(['private', 'no-cache']);
806+
expect(res.cacheControl?.maxAge).toBeUndefined();
807+
expect(res.cacheControl?.directives).not.toContain('max-age');
808+
});
796809
});
797810

798811
// ═══════════════════════════════════════════════════════════════

packages/objectql/src/protocol.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2653,8 +2653,16 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
26532653
etag,
26542654
lastModified: new Date().toISOString(),
26552655
cacheControl: {
2656-
directives: ['public', 'max-age'],
2657-
maxAge: 3600, // 1 hour
2656+
// Metadata is invalidated by publish, so freshness must be
2657+
// gated by the ETag validator — not a TTL. `no-cache` lets
2658+
// clients store the body but forces an `If-None-Match`
2659+
// revalidation on every use: a cheap 304 when unchanged,
2660+
// fresh fields the instant a publish bumps the ETag. The old
2661+
// `max-age=3600` pinned the schema for up to an hour, so the
2662+
// AI-build "New" form kept rendering pre-publish fields until
2663+
// the TTL lapsed (no revalidation in between). `private` also
2664+
// keeps per-tenant metadata out of shared CDN/proxy caches.
2665+
directives: ['private', 'no-cache'],
26582666
},
26592667
notModified: false,
26602668
};

packages/rest/src/rest-server.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2328,11 +2328,17 @@ export class RestServer {
23282328
res.header('Last-Modified', new Date(result.lastModified).toUTCString());
23292329
}
23302330
if (result.cacheControl) {
2331-
const directives = result.cacheControl.directives.join(', ');
2332-
const maxAge = result.cacheControl.maxAge
2333-
? `, max-age=${result.cacheControl.maxAge}`
2334-
: '';
2335-
res.header('Cache-Control', directives + maxAge);
2331+
// `max-age` is a placeholder directive in the
2332+
// array; its real value is appended from the
2333+
// `maxAge` field. Strip the bare token before
2334+
// joining so the two never collide into the
2335+
// malformed `public, max-age, max-age=3600`.
2336+
const parts: string[] = result.cacheControl.directives
2337+
.filter((d: string) => d !== 'max-age');
2338+
if (result.cacheControl.maxAge != null) {
2339+
parts.push(`max-age=${result.cacheControl.maxAge}`);
2340+
}
2341+
res.header('Cache-Control', parts.join(', '));
23362342
}
23372343

23382344
res.header('Vary', 'Accept-Language');

packages/rest/src/rest.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,74 @@ describe('RestServer', () => {
656656
});
657657
});
658658

659+
describe('meta single-item Cache-Control header (cached path)', () => {
660+
function getMetaItemRoute(rest: any) {
661+
return rest
662+
.getRoutes()
663+
.find((r: any) => r.method === 'GET' && r.path === '/api/v1/meta/:type/:name');
664+
}
665+
const mockRes = () => ({
666+
json: vi.fn(),
667+
status: vi.fn().mockReturnThis(),
668+
header: vi.fn(),
669+
send: vi.fn(),
670+
});
671+
const cacheControlOf = (res: any) =>
672+
res.header.mock.calls.find((c: any[]) => c[0] === 'Cache-Control')?.[1];
673+
674+
it('emits a revalidate-always header (no max-age TTL) for object meta reads', async () => {
675+
// Object metadata is invalidated by publish, so the response must force an
676+
// ETag revalidation rather than pin a stale schema for up to an hour — the
677+
// AI-build "New" form kept showing pre-publish fields under the old
678+
// `max-age=3600`. Mirrors the shape protocol.getMetaItemCached now returns.
679+
protocol.getMetaItemCached = vi.fn().mockResolvedValue({
680+
data: { name: 'lead' },
681+
etag: { value: 'abc', weak: false },
682+
cacheControl: { directives: ['private', 'no-cache'] },
683+
notModified: false,
684+
});
685+
const rest = new RestServer(server as any, protocol as any);
686+
rest.registerRoutes();
687+
const route = getMetaItemRoute(rest);
688+
expect(route).toBeDefined();
689+
690+
const res = mockRes();
691+
await route!.handler(
692+
{ params: { type: 'object', name: 'lead' }, query: {}, headers: {} },
693+
res,
694+
);
695+
696+
expect(protocol.getMetaItemCached).toHaveBeenCalled();
697+
expect(cacheControlOf(res)).toBe('private, no-cache');
698+
expect(cacheControlOf(res)).not.toMatch(/max-age/);
699+
});
700+
701+
it('appends max-age once and never the malformed bare-token duplicate', async () => {
702+
// Regression guard: a `max-age` placeholder directive in the array plus a
703+
// `maxAge` value used to concatenate into `public, max-age, max-age=3600`.
704+
// The bare token must be stripped so the header is a single well-formed
705+
// `max-age=N`.
706+
protocol.getMetaItemCached = vi.fn().mockResolvedValue({
707+
data: { name: 'lead' },
708+
etag: { value: 'abc', weak: false },
709+
cacheControl: { directives: ['public', 'max-age'], maxAge: 3600 },
710+
notModified: false,
711+
});
712+
const rest = new RestServer(server as any, protocol as any);
713+
rest.registerRoutes();
714+
const route = getMetaItemRoute(rest);
715+
716+
const res = mockRes();
717+
await route!.handler(
718+
{ params: { type: 'object', name: 'lead' }, query: {}, headers: {} },
719+
res,
720+
);
721+
722+
expect(cacheControlOf(res)).toBe('public, max-age=3600');
723+
expect(cacheControlOf(res)).not.toContain('max-age,');
724+
});
725+
});
726+
659727
describe('findData handler expand/populate forwarding', () => {
660728
function getListRoute(rest: any) {
661729
const routes = rest.getRoutes();

0 commit comments

Comments
 (0)