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
14 changes: 14 additions & 0 deletions .changeset/31196-cached-meta-item-last-modified.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/metadata-protocol': patch
---

fix(metadata-protocol): the cached `/meta` item read answers the item's own change time as `lastModified`, never the time of the read

Clause-②: no
Metadata-protocol: none

The cached by-name read (`getMetaItemCached`, the default `GET /api/v1/meta/:type/:name`) used to answer `lastModified` as the time of the read, so the `Last-Modified` header changed on every read of an unchanged item while its ETag stayed the same.

- **An item served from a stored `sys_metadata` row** answers that row's `updated_at`: the time of the save or publish that last wrote it. Two reads of the unchanged item answer the same `Last-Modified`; a save or a publish moves it.
- **An item no stored row serves** (a definition shipped in code or loaded from a file) has no known change time. It answers no `lastModified`, and the door sends no `Last-Modified` header.
- **Unchanged.** The ETag, `Cache-Control: private, no-cache` and the `304` on `If-None-Match`. `If-Modified-Since` still never answers a `304`. The by-name read's response envelope is byte-for-byte as before.
16 changes: 14 additions & 2 deletions packages/metadata-protocol/src/protocol-meta-item-reads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import {
withRowBinding,
} from './protocol-package-bindings.js';
import type { ServedOverrideRow } from './served-override.js';
import { withServedRowChangedAt } from './served-row-changed-at.js';
import { canonicalIsoInstant } from './sys-metadata-repository.js';

/** The body of `ObjectStackProtocolImplementation#getMetaItem`; the method's docblock there is its contract. */
export async function getMetaItem(host: Pick<ProtocolHost, 'readMetaItemFace'>, request: {
Expand Down Expand Up @@ -326,6 +328,10 @@ export async function readMetaItem(host: Pick<ProtocolHost, 'artifactLockLayerAt
let draftChanges: ReturnType<typeof storedDraftChanges>;
// [#26707] The draft row this read serves, for its publish verdict.
let servedDraftRow: { metadata?: unknown; package_id?: string | null } | undefined;
// [#31196] When the stored row whose content the active read adopts last
// changed: the item's own `Last-Modified` ({@link withServedRowChangedAt}).
// Unset for an item no stored row serves.
let servedRowChangedAt: string | undefined;
try {
// [#28409, ADR-0146 D8 item 2] The active read serves the
// package's name, so its declared override is the first candidate
Expand Down Expand Up @@ -389,6 +395,9 @@ export async function readMetaItem(host: Pick<ProtocolHost, 'artifactLockLayerAt
if (readState === 'draft') {
draftChanges = storedDraftChanges((record as { draft_changes?: unknown }).draft_changes);
servedDraftRow = record as { metadata?: unknown; package_id?: string | null };
} else {
const stamped = record as { updated_at?: unknown; created_at?: unknown };
servedRowChangedAt = canonicalIsoInstant(stamped.updated_at ?? stamped.created_at);
}
}
} catch (error) {
Expand Down Expand Up @@ -722,7 +731,10 @@ export async function readMetaItem(host: Pick<ProtocolHost, 'artifactLockLayerAt
const cascadeDeleteObjects = !execution && decorated !== undefined && decorated !== null
? host.resolveCascadeDeleteObjects(request.type, request.name)
: undefined;
return {
// [#31196] …and, off the record, when the stored row it serves last
// changed ({@link withServedRowChangedAt}), for the cached read's
// `lastModified`.
return withServedRowChangedAt({
type: request.type,
name: request.name,
item: decorated,
Expand Down Expand Up @@ -753,5 +765,5 @@ export async function readMetaItem(host: Pick<ProtocolHost, 'artifactLockLayerAt
...(readRegistration ? { registration: readRegistration(request.name) } : {}),
...(readDisabledActions ? { disabledActions: readDisabledActions(decorated) } : {}),
...(cascadeDeleteObjects !== undefined ? { cascadeDeleteObjects } : {}),
};
}, servedRowChangedAt);
}
12 changes: 11 additions & 1 deletion packages/metadata-protocol/src/protocol-search-and-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { canonicalizeMetaRequestType } from './protocol-meta-type-schema.js';
import { simpleHash } from './protocol-version-tokens.js';
import { metadataItemNotFoundError } from './protocol-client-failures.js';
import type { ProtocolHost } from './protocol-host.js';
import { servedRowChangedAtOf } from './served-row-changed-at.js';

export class ProtocolSearchAndCacheFacet {
/** The body of `ObjectStackProtocolImplementation#getMetaItemCached`; the method's docblock there is its contract. */
Expand Down Expand Up @@ -55,6 +56,15 @@ export class ProtocolSearchAndCacheFacet {
// [#28001] …and the objects a delete of one of its records can
// cascade into, on the same terms.
const cascadeDeleteObjects = (result as { cascadeDeleteObjects?: string[] } | undefined)?.cascadeDeleteObjects;
// [#31196] When the stored row this item is served from last
// changed: a save or a publish moves it, a read never does. An
// item no stored row serves (a code-shipped or file-loaded
// definition) has no known change time, so it answers none and
// the REST door sends no `Last-Modified`. ⛔ Never the time of
// the read: two reads of one unchanged item answered two values
// under one ETag. Freshness is the ETag's alone (`no-cache`
// below); nothing here answers a 304 from this time.
const lastModified = servedRowChangedAtOf(result);

if (!item) {
// [#5532] Structured: 404 + the catalog's `RESOURCE_NOT_FOUND`.
Expand Down Expand Up @@ -144,7 +154,7 @@ export class ProtocolSearchAndCacheFacet {
...(disabledActions !== undefined ? { disabledActions } : {}),
...(cascadeDeleteObjects !== undefined ? { cascadeDeleteObjects } : {}),
etag,
lastModified: new Date().toISOString(),
...(lastModified !== undefined ? { lastModified } : {}),
cacheControl: {
// Metadata is invalidated by publish, so freshness must be
// gated by the ETag validator — not a TTL. `no-cache` lets
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#31196] The cached by-name `/meta` read (`getMetaItemCached`) answers
* `lastModified` — the REST door's `Last-Modified` — as the item's own last
* change, never the time of the read.
*
* 1. Two reads of one unchanged stored item answer the same `lastModified`:
* the stored row's `updated_at`, the time of the save that wrote it.
* 2. A save and a publish of that item each move it to their own time.
* 3. An item no stored row serves (a code-shipped definition from the
* registry) has no known change time and answers no `lastModified` key.
* 4. CONTROL: the ETag and `cacheControl` (`private, no-cache`) are as they
* were — freshness stays the ETag's.
*
* The clock is fake and moved between steps, so a read stamped with the
* read time (the defect) differs from the save time by whole minutes, never
* by a sub-second accident.
*
* Ablation (one-shot, recorded in the PR): putting
* `lastModified: new Date().toISOString()` back in
* `protocol-search-and-cache.ts` turns §1, §2 and §3 red.
*/
// Pays the lint root's first load at collection, never inside a clocked case.
import './lint-root-preload.testkit.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
assertEngineDeleteDispatch,
assertEngineFindOnePredicate,
assertEngineUpdateDispatch,
} from '@objectstack/metadata-core';
import { ObjectStackProtocolImplementation } from './protocol.js';

type Row = Record<string, any>;

/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` or equality. */
const whereValueMatches = (actual: unknown, want: unknown): boolean =>
want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in)
? (want as { $in: unknown[] }).$in.includes(actual)
: actual === want;

function matches(r: Row, where: Record<string, unknown> = {}): boolean {
for (const [k, v] of Object.entries(where)) {
// A combinator is refused, never read as a field name.
if (k.startsWith('$')) throw new Error(`matches: unsupported combinator ${k}`);
if (v === undefined) continue;
if (!whereValueMatches(r[k] ?? null, v)) return false;
}
return true;
}

/** A `sys_metadata` store; every other table answers empty. `code` is the registry's code-shipped items. */
function makeStubEngine(code: Record<string, Row> = {}) {
const rows: Row[] = [];
let nextId = 0;
const engine: any = {
async findOne(t: string, opts: { where: Record<string, unknown> }) {
assertEngineFindOnePredicate(t, opts);
if (t !== 'sys_metadata') return null;
return rows.find((r) => matches(r, opts.where)) ?? null;
},
async find(t: string, opts?: { where?: Record<string, unknown>; limit?: number }) {
if (t !== 'sys_metadata') return [];
const found = rows.filter((r) => matches(r, opts?.where));
return typeof opts?.limit === 'number' ? found.slice(0, opts.limit) : found;
},
async count(t: string, opts?: { where?: Record<string, unknown> }) {
if (t !== 'sys_metadata') return 0;
return rows.filter((r) => matches(r, opts?.where)).length;
},
async insert(t: string, data: Row) {
if (t !== 'sys_metadata') return { id: 'side_table' };
const row = { id: `r_${++nextId}`, ...data };
rows.push(row);
return { id: row.id };
},
async update(t: string, data: Row, opts: { where: Record<string, unknown> }) {
assertEngineUpdateDispatch(data, opts);
if (t !== 'sys_metadata') return { id: null };
const found = rows.find((r) => matches(r, opts.where));
if (!found) return { id: null };
Object.assign(found, data);
return { id: found.id };
},
async delete(_t: string, opts?: Record<string, unknown>) {
assertEngineDeleteDispatch(opts);
return { deleted: 0 };
},
async transaction<T>(cb: (ctx: unknown, info: { owned: boolean }) => Promise<T>): Promise<T> {
return cb(undefined, { owned: true });
},
async syncObjectSchema() { /* no DDL in this stub */ },
registry: {
listItems: () => [],
isPackageDisabled: () => false,
getItem: (type: string, name: string) => (type === 'object' ? code[name] : undefined),
registerItem: () => {},
registerObject: () => {},
getPackage: () => undefined,
},
};
return { engine, rows };
}

/** A runtime-created business object (no artifact), so the save door writes a stored row. */
const authored = (name: string, label = 'Invoice') => ({
name,
label,
sharingModel: 'private',
fields: { amount: { type: 'currency', label: 'Amount' } },
});

const T_SAVE = '2026-11-07T08:00:00.000Z';
const T_READ_1 = '2026-11-07T08:05:00.000Z';
const T_READ_2 = '2026-11-07T08:09:00.000Z';
const T_RESAVE = '2026-11-07T09:00:00.000Z';
const T_DRAFT = '2026-11-07T10:00:00.000Z';
const T_PUBLISH = '2026-11-07T10:30:00.000Z';
const T_READ_3 = '2026-11-07T11:00:00.000Z';

describe('[#31196] the cached by-name read answers the item\'s own change time, never the read time', () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] });
});
afterEach(() => {
vi.useRealTimers();
});

it('§1 two reads of one unchanged stored item answer the same lastModified — the save time', async () => {
const { engine } = makeStubEngine();
const protocol = new ObjectStackProtocolImplementation(engine);
vi.setSystemTime(new Date(T_SAVE));
await protocol.saveMetaItem({ type: 'object', name: 'invoice', item: authored('invoice') });

vi.setSystemTime(new Date(T_READ_1));
const first = await protocol.getMetaItemCached({ type: 'object', name: 'invoice' });
vi.setSystemTime(new Date(T_READ_2));
const second = await protocol.getMetaItemCached({ type: 'object', name: 'invoice' });

expect(first.lastModified).toBe(T_SAVE);
expect(second.lastModified).toBe(T_SAVE);
// CONTROL (§4): the same document answers the same validator and the
// same freshness rule as before.
expect(second.etag).toEqual(first.etag);
expect(first.cacheControl).toEqual({ directives: ['private', 'no-cache'] });
expect(first.notModified).toBe(false);
});

it('§2 a save moves it, and so does a publish', async () => {
const { engine } = makeStubEngine();
const protocol = new ObjectStackProtocolImplementation(engine);
vi.setSystemTime(new Date(T_SAVE));
await protocol.saveMetaItem({ type: 'object', name: 'invoice', item: authored('invoice') });
vi.setSystemTime(new Date(T_READ_1));
const before = await protocol.getMetaItemCached({ type: 'object', name: 'invoice' });

vi.setSystemTime(new Date(T_RESAVE));
await protocol.saveMetaItem({ type: 'object', name: 'invoice', item: authored('invoice', 'Bill') });
vi.setSystemTime(new Date(T_READ_2));
const afterSave = await protocol.getMetaItemCached({ type: 'object', name: 'invoice' });

vi.setSystemTime(new Date(T_DRAFT));
await protocol.saveMetaItem({ type: 'object', name: 'invoice', item: authored('invoice', 'Receipt'), mode: 'draft' });
// A pending draft is not the served item: the active row's time stands.
const withDraft = await protocol.getMetaItemCached({ type: 'object', name: 'invoice' });
vi.setSystemTime(new Date(T_PUBLISH));
await protocol.publishMetaItem({ type: 'object', name: 'invoice' });
vi.setSystemTime(new Date(T_READ_3));
const afterPublish = await protocol.getMetaItemCached({ type: 'object', name: 'invoice' });

expect(before.lastModified).toBe(T_SAVE);
expect(afterSave.lastModified).toBe(T_RESAVE);
expect(withDraft.lastModified).toBe(T_RESAVE);
expect((afterPublish.data as { label?: string }).label).toBe('Receipt');
expect(afterPublish.lastModified).toBe(T_PUBLISH);
// CONTROL (§4): each change also moved the validator.
expect(afterSave.etag).not.toEqual(before.etag);
expect(afterPublish.etag).not.toEqual(afterSave.etag);
});

it('§3 an item no stored row serves has no known change time: no lastModified key', async () => {
const { engine } = makeStubEngine({ shipped: { ...authored('shipped', 'Shipped'), _packageId: 'com.example.code' } });
const protocol = new ObjectStackProtocolImplementation(engine);
vi.setSystemTime(new Date(T_READ_1));
const read = await protocol.getMetaItemCached({ type: 'object', name: 'shipped' });

expect((read.data as { label?: string }).label).toBe('Shipped');
expect(Object.keys(read)).not.toContain('lastModified');
// CONTROL (§4): it still carries its validator and freshness rule.
expect(read.etag?.value).toEqual(expect.any(String));
expect(read.cacheControl).toEqual({ directives: ['private', 'no-cache'] });
});

it('§4 the change time stays off the by-name read\'s published envelope', async () => {
const { engine } = makeStubEngine();
const protocol = new ObjectStackProtocolImplementation(engine);
vi.setSystemTime(new Date(T_SAVE));
await protocol.saveMetaItem({ type: 'object', name: 'invoice', item: authored('invoice') });
const envelope = await protocol.getMetaItem({ type: 'object', name: 'invoice' });

expect(JSON.stringify(envelope)).not.toContain(T_SAVE);
expect(Object.keys(envelope)).not.toContain('lastModified');
});
});
67 changes: 67 additions & 0 deletions packages/metadata-protocol/src/served-row-changed-at.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* # When the stored row a by-name read served last changed (#31196)
*
* The cached `/meta` item read (`getMetaItemCached`) answers `lastModified`,
* which the REST door renders as `Last-Modified`. It used to be the time of
* the READ (`new Date()`), so two reads of one unchanged item a second apart
* answered two different values under one ETag, and the header carried no
* information about the item.
*
* The item's own change time is known in exactly one place: the
* `sys_metadata` row the by-name read ({@link readMetaItem}) adopts as the
* item's content. Its `updated_at` is stamped by every write of that row, a
* save and a publish alike (`SysMetadataRepository`). An item served from the
* registry or the MetadataService (a code-shipped or file-loaded definition)
* has no recorded change time, so it carries none, and the header is
* omitted. ⛔ Never a stand-in: not the read time, not the boot time.
*
* The read records the row's time on the envelope it returns, and the cached
* read asks {@link servedRowChangedAtOf}.
*
* ## Why a symbol, and why non-enumerable
*
* - **It never leaves the process.** `JSON.stringify` drops it, so the
* by-name read's published envelope (`GetMetaItemResponseSchema`), its REST
* and MCP answers, and every pin of them are unchanged.
* - **It does not travel on a copy.** An object spread does not carry it, so
* an envelope rebuilt elsewhere (the disabled-package withholding, a
* wrapper) answers `undefined`, "no change time known", which omits the
* header: the safe direction.
*
* `Symbol.for`, so a second evaluation of this module (the dual ESM/CJS
* builds) reads what the first one wrote.
*/

const SERVED_ROW_CHANGED_AT: unique symbol = Symbol.for('objectstack.metadata-protocol.servedRowChangedAt') as never;

/**
* Record on `envelope` the change time of the stored row whose content it
* serves (canonical ISO-8601 text). Mutates and returns `envelope`; with no
* time (`undefined`) nothing is recorded.
*
* Only {@link readMetaItem} calls this, on its active-state answer.
*/
export function withServedRowChangedAt<T extends object>(envelope: T, changedAt: string | undefined): T {
if (changedAt === undefined) return envelope;
Object.defineProperty(envelope, SERVED_ROW_CHANGED_AT, {
value: changedAt,
enumerable: false,
writable: false,
configurable: true,
});
return envelope;
}

/**
* The change time {@link withServedRowChangedAt} recorded on a by-name read's
* envelope, or `undefined` when the item was not served from a stored row, or
* the envelope is a copy.
*/
export function servedRowChangedAtOf(envelope: unknown): string | undefined {
if (envelope === null || typeof envelope !== 'object') return undefined;
if (!Object.prototype.hasOwnProperty.call(envelope, SERVED_ROW_CHANGED_AT)) return undefined;
const value = (envelope as { [SERVED_ROW_CHANGED_AT]?: unknown })[SERVED_ROW_CHANGED_AT];
return typeof value === 'string' && value !== '' ? value : undefined;
}
2 changes: 1 addition & 1 deletion packages/metadata-protocol/src/sys-metadata-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ import { draftChangedPaths, mergeDraftChanges, storedDraftChanges, type DraftCha
* promises its callers the RAW value back, a contract this domain rewrite
* would reverse.
*/
function canonicalIsoInstant(value: unknown): string | undefined {
export function canonicalIsoInstant(value: unknown): string | undefined {
if (value === null || value === undefined) return undefined;
// [#14078] Total `Date` arm: an Invalid `Date` is the ONE `Date` shape
// `toISOString()` refuses, and it is reachable from two live drivers. It
Expand Down
Loading
Loading