diff --git a/.changeset/meta-item-cache-control-no-cache.md b/.changeset/meta-item-cache-control-no-cache.md new file mode 100644 index 0000000000..2e385c7289 --- /dev/null +++ b/.changeset/meta-item-cache-control-no-cache.md @@ -0,0 +1,26 @@ +--- +"@objectstack/objectql": patch +"@objectstack/rest": patch +--- + +fix(objectql,rest): single-item meta reads must revalidate (no `max-age=3600`) + +`GET /api/v1/meta/object/:name` (and the other single-item meta reads served by +the cached path) sent `Cache-Control: public, max-age, max-age=3600`. Two bugs: + +1. **Stale metadata for up to an hour.** Object metadata is invalidated by + publish, but a one-hour TTL let browsers (and any CDN/proxy) serve a stale + schema *without revalidating* — e.g. the AI-build "New" create form kept + rendering pre-publish fields until the TTL lapsed. The list endpoint + `GET /api/v1/meta/object` is uncached, which is why list views updated but + single-object reads didn't. `getMetaItemCached` now returns + `directives: ['private', 'no-cache']` with no `maxAge`, so the ETag validator + (which already changes on publish) gates freshness: a cheap `304` when + unchanged, fresh fields the instant a publish bumps the ETag. `private` also + keeps per-tenant metadata out of shared caches. + +2. **Malformed header.** The directives array carried a bare `max-age` + placeholder *and* the REST layer appended `max-age=3600` from the `maxAge` + field, concatenating into `public, max-age, max-age=3600`. The header builder + now strips the bare `max-age` token before appending the real value, so a + `maxAge` is emitted once as a well-formed `max-age=N`. diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index 3129818c29..59379961ab 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -793,6 +793,19 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { }); expect(second.notModified).toBe(true); }); + + it('returns a revalidate-always cache-control with no max-age TTL', async () => { + // Metadata is invalidated by publish, so freshness must be gated by + // the ETag validator — not a TTL. A `max-age` here pinned a stale + // schema (e.g. the AI-build "New" form showing pre-publish fields) + // for up to an hour with no revalidation in between. `private` also + // keeps per-tenant metadata out of shared CDN/proxy caches. + const res = await protocol.getMetaItemCached({ type: 'object', name: 'customer' }); + expect(res.notModified).toBe(false); + expect(res.cacheControl?.directives).toEqual(['private', 'no-cache']); + expect(res.cacheControl?.maxAge).toBeUndefined(); + expect(res.cacheControl?.directives).not.toContain('max-age'); + }); }); // ═══════════════════════════════════════════════════════════════ diff --git a/packages/objectql/src/protocol.ts b/packages/objectql/src/protocol.ts index 045943ec62..d8fca81a7d 100644 --- a/packages/objectql/src/protocol.ts +++ b/packages/objectql/src/protocol.ts @@ -2653,8 +2653,16 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { etag, lastModified: new Date().toISOString(), cacheControl: { - directives: ['public', 'max-age'], - maxAge: 3600, // 1 hour + // Metadata is invalidated by publish, so freshness must be + // gated by the ETag validator — not a TTL. `no-cache` lets + // clients store the body but forces an `If-None-Match` + // revalidation on every use: a cheap 304 when unchanged, + // fresh fields the instant a publish bumps the ETag. The old + // `max-age=3600` pinned the schema for up to an hour, so the + // AI-build "New" form kept rendering pre-publish fields until + // the TTL lapsed (no revalidation in between). `private` also + // keeps per-tenant metadata out of shared CDN/proxy caches. + directives: ['private', 'no-cache'], }, notModified: false, }; diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 3142c9e82d..b3e8fcd166 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -2328,11 +2328,17 @@ export class RestServer { res.header('Last-Modified', new Date(result.lastModified).toUTCString()); } if (result.cacheControl) { - const directives = result.cacheControl.directives.join(', '); - const maxAge = result.cacheControl.maxAge - ? `, max-age=${result.cacheControl.maxAge}` - : ''; - res.header('Cache-Control', directives + maxAge); + // `max-age` is a placeholder directive in the + // array; its real value is appended from the + // `maxAge` field. Strip the bare token before + // joining so the two never collide into the + // malformed `public, max-age, max-age=3600`. + const parts: string[] = result.cacheControl.directives + .filter((d: string) => d !== 'max-age'); + if (result.cacheControl.maxAge != null) { + parts.push(`max-age=${result.cacheControl.maxAge}`); + } + res.header('Cache-Control', parts.join(', ')); } res.header('Vary', 'Accept-Language'); diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index 3ed6cfa7c9..4e604753c9 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -656,6 +656,74 @@ describe('RestServer', () => { }); }); + describe('meta single-item Cache-Control header (cached path)', () => { + function getMetaItemRoute(rest: any) { + return rest + .getRoutes() + .find((r: any) => r.method === 'GET' && r.path === '/api/v1/meta/:type/:name'); + } + const mockRes = () => ({ + json: vi.fn(), + status: vi.fn().mockReturnThis(), + header: vi.fn(), + send: vi.fn(), + }); + const cacheControlOf = (res: any) => + res.header.mock.calls.find((c: any[]) => c[0] === 'Cache-Control')?.[1]; + + it('emits a revalidate-always header (no max-age TTL) for object meta reads', async () => { + // Object metadata is invalidated by publish, so the response must force an + // ETag revalidation rather than pin a stale schema for up to an hour — the + // AI-build "New" form kept showing pre-publish fields under the old + // `max-age=3600`. Mirrors the shape protocol.getMetaItemCached now returns. + protocol.getMetaItemCached = vi.fn().mockResolvedValue({ + data: { name: 'lead' }, + etag: { value: 'abc', weak: false }, + cacheControl: { directives: ['private', 'no-cache'] }, + notModified: false, + }); + const rest = new RestServer(server as any, protocol as any); + rest.registerRoutes(); + const route = getMetaItemRoute(rest); + expect(route).toBeDefined(); + + const res = mockRes(); + await route!.handler( + { params: { type: 'object', name: 'lead' }, query: {}, headers: {} }, + res, + ); + + expect(protocol.getMetaItemCached).toHaveBeenCalled(); + expect(cacheControlOf(res)).toBe('private, no-cache'); + expect(cacheControlOf(res)).not.toMatch(/max-age/); + }); + + it('appends max-age once and never the malformed bare-token duplicate', async () => { + // Regression guard: a `max-age` placeholder directive in the array plus a + // `maxAge` value used to concatenate into `public, max-age, max-age=3600`. + // The bare token must be stripped so the header is a single well-formed + // `max-age=N`. + protocol.getMetaItemCached = vi.fn().mockResolvedValue({ + data: { name: 'lead' }, + etag: { value: 'abc', weak: false }, + cacheControl: { directives: ['public', 'max-age'], maxAge: 3600 }, + notModified: false, + }); + const rest = new RestServer(server as any, protocol as any); + rest.registerRoutes(); + const route = getMetaItemRoute(rest); + + const res = mockRes(); + await route!.handler( + { params: { type: 'object', name: 'lead' }, query: {}, headers: {} }, + res, + ); + + expect(cacheControlOf(res)).toBe('public, max-age=3600'); + expect(cacheControlOf(res)).not.toContain('max-age,'); + }); + }); + describe('findData handler expand/populate forwarding', () => { function getListRoute(rest: any) { const routes = rest.getRoutes();