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
26 changes: 26 additions & 0 deletions .changeset/meta-item-cache-control-no-cache.md
Original file line number Diff line number Diff line change
@@ -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`.
13 changes: 13 additions & 0 deletions packages/objectql/src/protocol-meta.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});

// ═══════════════════════════════════════════════════════════════
Expand Down
12 changes: 10 additions & 2 deletions packages/objectql/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
16 changes: 11 additions & 5 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
68 changes: 68 additions & 0 deletions packages/rest/src/rest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down