Skip to content

Commit c09ffc1

Browse files
fix(metadata-protocol): the cached /meta item read answers the item's own change time as Last-Modified, never the read time (#31248)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XKQgHXwRFWSGUn8kEwFcvFL)_ Fixes #31196 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:元数据按名读取的缓存出口(默认的 `GET /api/v1/meta/:type/:name`)里,`Last-Modified` 不再填「这次读取的时间」。条目来自 `sys_metadata` 存储行时,填该行的 `updated_at`(最近一次保存或发布的时间);条目来自代码或文件、平台不知道它何时改过时,不发这个头。 - 为什么改:同一条没改过的元数据,两次读到的 `Last-Modified` 不一样而 ETag 相同,这个头在说假话,还让 #31171 的测试在合并队列里抖过两次。 - 风险与回滚:只改头的取值;ETag、`Cache-Control: private, no-cache`、`If-None-Match` 的 304 全部不变,按名读取的响应信封逐字节不变(时间走进程内不可序列化的符号,不进 JSON)。回滚即 revert。 - 先看:`packages/metadata-protocol/src/protocol-meta-item-reads.ts`(采用存储行处记下 `updated_at`,返回信封时挂上);`packages/metadata-protocol/src/protocol-search-and-cache.ts`(`lastModified` 改读它,没有就不给键);新文件 `served-row-changed-at.ts`。 - 两分钟验证:`pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.cached-item-last-modified.test.ts`,预期 4 个用例全过:两次读同一个时间、保存和发布各自推进它、代码条目没有 `lastModified` 键。 - 需要你做的:无,席位落地。 ## What changes - `getMetaItemCached` (`protocol-search-and-cache.ts`) answered `lastModified: new Date().toISOString()`, the time of the read. It now answers the change time of the stored `sys_metadata` row the item is served from, and no `lastModified` key when no stored row serves it. The REST door already renders `Last-Modified` only when the key is present (`meta-item-read-routes.ts`, unchanged), so a code-shipped item answers no header. - The time is known in one place: the by-name read (`readMetaItem`, `protocol-meta-item-reads.ts`) holds the row it adopts as the item's content. It records `canonicalIsoInstant(row.updated_at ?? row.created_at)` on its active-state envelope as a non-enumerable `Symbol.for` key (`served-row-changed-at.ts`, the `stored-row-binding.ts` pattern). `JSON.stringify` drops it and a spread does not copy it, so the published `GetMetaItemResponseSchema` envelope, its REST and MCP answers, and every `toEqual` pin are unchanged. The cached read asks `servedRowChangedAtOf(result)`. - `canonicalIsoInstant` in `sys-metadata-repository.ts` is now exported (module-internal; `index.ts` does not re-export it), so the read uses the repository's one spelling of a driver timestamp. - Changeset `.changeset/31196-cached-meta-item-last-modified.md`: `@objectstack/metadata-protocol` patch. `@objectstack/rest` gains a test only. ## Tests - `packages/metadata-protocol/src/protocol.cached-item-last-modified.test.ts` (real protocol, stub `sys_metadata` store, fake `Date` moved minutes between steps): - §1 two reads of one unchanged stored item (5 and 9 minutes after the save) both answer the save time; CONTROL: same ETag, `cacheControl` `['private','no-cache']`. - §2 a save moves it to the save time; a pending draft does not move it; a publish moves it to the publish time; CONTROL: each change also moves the ETag. - §3 a code-shipped item (registry, no stored row) has no `lastModified` key; CONTROL: it keeps its ETag and `cacheControl`. - §4 the change time is not in the by-name read's serialised envelope. - `packages/rest/src/meta-item-cached-last-modified.test.ts` (the door, protocol double): renders the protocol's time as `Sat, 07 Nov 2026 08:00:00 GMT` and the same on a second read; sends no `Last-Modified` when the key is absent while ETag and `Cache-Control` stay; `If-Modified-Since` answers 200 with the body, with or without a time. Ablation (os-dev, via `node scripts/ablation-replace.mjs`, the test resolves the protocol from `src/`): anchor `...(lastModified !== undefined ? { lastModified } : {}),` replaced by `lastModified: new Date().toISOString(),` (anchor 1 → 0, replacement 0 → 1, blob c29eebf → c033ddc6), then the pin file: <details><summary>Ablation red: 3 failed | 1 passed (§4 stays green, as predicted: it reads the by-name envelope, not the cached answer)</summary> ``` ❯ src/protocol.cached-item-last-modified.test.ts (4 tests | 3 failed) 82ms × §1 two reads of one unchanged stored item answer the same lastModified — the save time 48ms × §2 a save moves it, and so does a publish 26ms × §3 an item no stored row serves has no known change time: no lastModified key 4ms AssertionError: expected '2026-11-07T08:05:00.000Z' to be '2026-11-07T08:00:00.000Z' // Object.is equality AssertionError: expected [ Array(5) ] to not include 'lastModified' ablation-replace: ok restored: blob == HEAD (c29eebf) and `git diff HEAD` is empty ``` </details> ## Acceptance notes - **Mechanism, measured on `origin/main` 66bf5b4.** The order's hypothesis holds: the producer stamped the read time and the REST door only rendered it. The door needed no change. - **Where the change time is reachable.** Not at `getMetaItemCached`: its only input is `getMetaItem`'s envelope, whose `version` is a hash digest of the stored head (`readVersionToken` → `receiptVersion`), not a time. The row is in hand inside `readMetaItem`, so the file surface grew past the claim's by `protocol-meta-item-reads.ts`, the new `served-row-changed-at.ts`, a one-word `export` in `sys-metadata-repository.ts`, and the engine-double ledger row for the new test (`scripts/engine-double-contract.pinned.json`, written by `check-engine-double-contract.mjs --write`). - **What counts as the item's change time.** The adopted row's `updated_at`, which `SysMetadataRepository` stamps on every write of the row, a save and a publish alike (§2 measures both), `created_at` for a row without one. A stored row the read declines (a shipped flow name's row, ADR-0146 D9) does not lend its time: the served item is then the code's, and answers none. - **Boundary, stated rather than hidden.** A stored object's served body also carries code-side contributions folded at read time (`extend` fields from installed packages, the artifact's lock envelope). Installing a package that changes them moves the ETag but not this `Last-Modified`, which states the stored row's change as the card's ruling names it. Nothing judges freshness from it: the ETag does (`no-cache`). - **`If-Modified-Since`, measured.** The door forwards it as `cacheRequest.ifModifiedSince`; the protocol never reads that member, and the door's own 304 compares `If-None-Match` only. So no request is answered 304 from a time, before or after this change; the REST pin records it. - **Precision.** `Last-Modified` is whole seconds (`toUTCString()`), so two writes within one second share a header value; the ETag still separates them. - **Not touched, carrier: seat.** `packages/runtime/src/domains/mcp-object-schema-mask.test.ts` (PR #31195) masks `last-modified` with a comment saying the protocol stamps the read time. After this PR that sentence is false for `Last-Modified` (the `Date` header half still holds); the masking itself stays harmless. Outside the claimed surface. ## Verification Head `7ff2b9e5`. `origin/main` was merged once before the PR, a no-op at 66bf5b4; main has since moved to 30f23fe, and none of its 141 changed files is one this PR touches (one other `packages/metadata-protocol` test file). - `pnpm --filter @objectstack/metadata-protocol test` (on f59f3fa+565bea38; the two later commits change only the two new test files and the ledger row, and both pin files were re-run green on 7ff2b9e: 4/4 and 3/3): Test Files 323 passed | 4 skipped (327), Tests 22531 passed | 26 skipped (lock VERDICT command-exit 0). - `pnpm --filter @objectstack/rest test`: Test Files 394 passed (394), Tests 7489 passed | 332 skipped (VERDICT command-exit 0). - `pnpm --filter @objectstack/metadata-protocol --filter @objectstack/rest typecheck`: exit 0 (metadata-protocol's `tsc` includes `src/**/*`, the new test listed by `--listFiles`; rest runs `check:test-typecheck`, 0 errors). - Lint, scoped: `node --stack-size=4000 node_modules/eslint/bin/eslint.js packages/metadata-protocol packages/rest --no-inline-config`, exit 0. - Derived gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` named 69 families for this diff; all 69 ran on `7ff2b9e5`, exit 0 each. `--ran`: "69 derived famil(ies) accounted for — 69 run, 0 NOT-MEASURED". `check-plugin-teardown-shape --self-test` needed `git fetch --depth=1 origin 621a487` on this shallow clone first. - Earlier rounds of the same union were red on the new test doubles only, each fixed in the test: `check:engine-double-contract` (ledger rows written with `--write`), `check:objectql-double-limit` (the `find` double now honours `limit`), `check:where-matcher` (the matcher refuses `$` combinators), `check:test-source-alias` (module-top preloads: `./lint-root-preload.testkit.js` and `@objectstack/objectql`). - CI families are read from this PR's check-runs, not re-run here. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5p9eu5KqN5GP7JZAGe)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bdefd49 commit c09ffc1

8 files changed

Lines changed: 431 additions & 4 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): the cached `/meta` item read answers the item's own change time as `lastModified`, never the time of the read
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
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.
11+
12+
- **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.
13+
- **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.
14+
- **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.

‎packages/metadata-protocol/src/protocol-meta-item-reads.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ import {
4747
withRowBinding,
4848
} from './protocol-package-bindings.js';
4949
import type { ServedOverrideRow } from './served-override.js';
50+
import { withServedRowChangedAt } from './served-row-changed-at.js';
51+
import { canonicalIsoInstant } from './sys-metadata-repository.js';
5052

5153
/** The body of `ObjectStackProtocolImplementation#getMetaItem`; the method's docblock there is its contract. */
5254
export async function getMetaItem(host: Pick<ProtocolHost, 'readMetaItemFace'>, request: {
@@ -326,6 +328,10 @@ export async function readMetaItem(host: Pick<ProtocolHost, 'artifactLockLayerAt
326328
let draftChanges: ReturnType<typeof storedDraftChanges>;
327329
// [#26707] The draft row this read serves, for its publish verdict.
328330
let servedDraftRow: { metadata?: unknown; package_id?: string | null } | undefined;
331+
// [#31196] When the stored row whose content the active read adopts last
332+
// changed: the item's own `Last-Modified` ({@link withServedRowChangedAt}).
333+
// Unset for an item no stored row serves.
334+
let servedRowChangedAt: string | undefined;
329335
try {
330336
// [#28409, ADR-0146 D8 item 2] The active read serves the
331337
// package's name, so its declared override is the first candidate
@@ -389,6 +395,9 @@ export async function readMetaItem(host: Pick<ProtocolHost, 'artifactLockLayerAt
389395
if (readState === 'draft') {
390396
draftChanges = storedDraftChanges((record as { draft_changes?: unknown }).draft_changes);
391397
servedDraftRow = record as { metadata?: unknown; package_id?: string | null };
398+
} else {
399+
const stamped = record as { updated_at?: unknown; created_at?: unknown };
400+
servedRowChangedAt = canonicalIsoInstant(stamped.updated_at ?? stamped.created_at);
392401
}
393402
}
394403
} catch (error) {
@@ -722,7 +731,10 @@ export async function readMetaItem(host: Pick<ProtocolHost, 'artifactLockLayerAt
722731
const cascadeDeleteObjects = !execution && decorated !== undefined && decorated !== null
723732
? host.resolveCascadeDeleteObjects(request.type, request.name)
724733
: undefined;
725-
return {
734+
// [#31196] …and, off the record, when the stored row it serves last
735+
// changed ({@link withServedRowChangedAt}), for the cached read's
736+
// `lastModified`.
737+
return withServedRowChangedAt({
726738
type: request.type,
727739
name: request.name,
728740
item: decorated,
@@ -753,5 +765,5 @@ export async function readMetaItem(host: Pick<ProtocolHost, 'artifactLockLayerAt
753765
...(readRegistration ? { registration: readRegistration(request.name) } : {}),
754766
...(readDisabledActions ? { disabledActions: readDisabledActions(decorated) } : {}),
755767
...(cascadeDeleteObjects !== undefined ? { cascadeDeleteObjects } : {}),
756-
};
768+
}, servedRowChangedAt);
757769
}

‎packages/metadata-protocol/src/protocol-search-and-cache.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { canonicalizeMetaRequestType } from './protocol-meta-type-schema.js';
2626
import { simpleHash } from './protocol-version-tokens.js';
2727
import { metadataItemNotFoundError } from './protocol-client-failures.js';
2828
import type { ProtocolHost } from './protocol-host.js';
29+
import { servedRowChangedAtOf } from './served-row-changed-at.js';
2930

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

5969
if (!item) {
6070
// [#5532] Structured: 404 + the catalog's `RESOURCE_NOT_FOUND`.
@@ -144,7 +154,7 @@ export class ProtocolSearchAndCacheFacet {
144154
...(disabledActions !== undefined ? { disabledActions } : {}),
145155
...(cascadeDeleteObjects !== undefined ? { cascadeDeleteObjects } : {}),
146156
etag,
147-
lastModified: new Date().toISOString(),
157+
...(lastModified !== undefined ? { lastModified } : {}),
148158
cacheControl: {
149159
// Metadata is invalidated by publish, so freshness must be
150160
// gated by the ETag validator — not a TTL. `no-cache` lets
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#31196] The cached by-name `/meta` read (`getMetaItemCached`) answers
5+
* `lastModified` — the REST door's `Last-Modified` — as the item's own last
6+
* change, never the time of the read.
7+
*
8+
* 1. Two reads of one unchanged stored item answer the same `lastModified`:
9+
* the stored row's `updated_at`, the time of the save that wrote it.
10+
* 2. A save and a publish of that item each move it to their own time.
11+
* 3. An item no stored row serves (a code-shipped definition from the
12+
* registry) has no known change time and answers no `lastModified` key.
13+
* 4. CONTROL: the ETag and `cacheControl` (`private, no-cache`) are as they
14+
* were — freshness stays the ETag's.
15+
*
16+
* The clock is fake and moved between steps, so a read stamped with the
17+
* read time (the defect) differs from the save time by whole minutes, never
18+
* by a sub-second accident.
19+
*
20+
* Ablation (one-shot, recorded in the PR): putting
21+
* `lastModified: new Date().toISOString()` back in
22+
* `protocol-search-and-cache.ts` turns §1, §2 and §3 red.
23+
*/
24+
// Pays the lint root's first load at collection, never inside a clocked case.
25+
import './lint-root-preload.testkit.js';
26+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
27+
import {
28+
assertEngineDeleteDispatch,
29+
assertEngineFindOnePredicate,
30+
assertEngineUpdateDispatch,
31+
} from '@objectstack/metadata-core';
32+
import { ObjectStackProtocolImplementation } from './protocol.js';
33+
34+
type Row = Record<string, any>;
35+
36+
/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` or equality. */
37+
const whereValueMatches = (actual: unknown, want: unknown): boolean =>
38+
want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in)
39+
? (want as { $in: unknown[] }).$in.includes(actual)
40+
: actual === want;
41+
42+
function matches(r: Row, where: Record<string, unknown> = {}): boolean {
43+
for (const [k, v] of Object.entries(where)) {
44+
// A combinator is refused, never read as a field name.
45+
if (k.startsWith('$')) throw new Error(`matches: unsupported combinator ${k}`);
46+
if (v === undefined) continue;
47+
if (!whereValueMatches(r[k] ?? null, v)) return false;
48+
}
49+
return true;
50+
}
51+
52+
/** A `sys_metadata` store; every other table answers empty. `code` is the registry's code-shipped items. */
53+
function makeStubEngine(code: Record<string, Row> = {}) {
54+
const rows: Row[] = [];
55+
let nextId = 0;
56+
const engine: any = {
57+
async findOne(t: string, opts: { where: Record<string, unknown> }) {
58+
assertEngineFindOnePredicate(t, opts);
59+
if (t !== 'sys_metadata') return null;
60+
return rows.find((r) => matches(r, opts.where)) ?? null;
61+
},
62+
async find(t: string, opts?: { where?: Record<string, unknown>; limit?: number }) {
63+
if (t !== 'sys_metadata') return [];
64+
const found = rows.filter((r) => matches(r, opts?.where));
65+
return typeof opts?.limit === 'number' ? found.slice(0, opts.limit) : found;
66+
},
67+
async count(t: string, opts?: { where?: Record<string, unknown> }) {
68+
if (t !== 'sys_metadata') return 0;
69+
return rows.filter((r) => matches(r, opts?.where)).length;
70+
},
71+
async insert(t: string, data: Row) {
72+
if (t !== 'sys_metadata') return { id: 'side_table' };
73+
const row = { id: `r_${++nextId}`, ...data };
74+
rows.push(row);
75+
return { id: row.id };
76+
},
77+
async update(t: string, data: Row, opts: { where: Record<string, unknown> }) {
78+
assertEngineUpdateDispatch(data, opts);
79+
if (t !== 'sys_metadata') return { id: null };
80+
const found = rows.find((r) => matches(r, opts.where));
81+
if (!found) return { id: null };
82+
Object.assign(found, data);
83+
return { id: found.id };
84+
},
85+
async delete(_t: string, opts?: Record<string, unknown>) {
86+
assertEngineDeleteDispatch(opts);
87+
return { deleted: 0 };
88+
},
89+
async transaction<T>(cb: (ctx: unknown, info: { owned: boolean }) => Promise<T>): Promise<T> {
90+
return cb(undefined, { owned: true });
91+
},
92+
async syncObjectSchema() { /* no DDL in this stub */ },
93+
registry: {
94+
listItems: () => [],
95+
isPackageDisabled: () => false,
96+
getItem: (type: string, name: string) => (type === 'object' ? code[name] : undefined),
97+
registerItem: () => {},
98+
registerObject: () => {},
99+
getPackage: () => undefined,
100+
},
101+
};
102+
return { engine, rows };
103+
}
104+
105+
/** A runtime-created business object (no artifact), so the save door writes a stored row. */
106+
const authored = (name: string, label = 'Invoice') => ({
107+
name,
108+
label,
109+
sharingModel: 'private',
110+
fields: { amount: { type: 'currency', label: 'Amount' } },
111+
});
112+
113+
const T_SAVE = '2026-11-07T08:00:00.000Z';
114+
const T_READ_1 = '2026-11-07T08:05:00.000Z';
115+
const T_READ_2 = '2026-11-07T08:09:00.000Z';
116+
const T_RESAVE = '2026-11-07T09:00:00.000Z';
117+
const T_DRAFT = '2026-11-07T10:00:00.000Z';
118+
const T_PUBLISH = '2026-11-07T10:30:00.000Z';
119+
const T_READ_3 = '2026-11-07T11:00:00.000Z';
120+
121+
describe('[#31196] the cached by-name read answers the item\'s own change time, never the read time', () => {
122+
beforeEach(() => {
123+
vi.useFakeTimers({ toFake: ['Date'] });
124+
});
125+
afterEach(() => {
126+
vi.useRealTimers();
127+
});
128+
129+
it('§1 two reads of one unchanged stored item answer the same lastModified — the save time', async () => {
130+
const { engine } = makeStubEngine();
131+
const protocol = new ObjectStackProtocolImplementation(engine);
132+
vi.setSystemTime(new Date(T_SAVE));
133+
await protocol.saveMetaItem({ type: 'object', name: 'invoice', item: authored('invoice') });
134+
135+
vi.setSystemTime(new Date(T_READ_1));
136+
const first = await protocol.getMetaItemCached({ type: 'object', name: 'invoice' });
137+
vi.setSystemTime(new Date(T_READ_2));
138+
const second = await protocol.getMetaItemCached({ type: 'object', name: 'invoice' });
139+
140+
expect(first.lastModified).toBe(T_SAVE);
141+
expect(second.lastModified).toBe(T_SAVE);
142+
// CONTROL (§4): the same document answers the same validator and the
143+
// same freshness rule as before.
144+
expect(second.etag).toEqual(first.etag);
145+
expect(first.cacheControl).toEqual({ directives: ['private', 'no-cache'] });
146+
expect(first.notModified).toBe(false);
147+
});
148+
149+
it('§2 a save moves it, and so does a publish', async () => {
150+
const { engine } = makeStubEngine();
151+
const protocol = new ObjectStackProtocolImplementation(engine);
152+
vi.setSystemTime(new Date(T_SAVE));
153+
await protocol.saveMetaItem({ type: 'object', name: 'invoice', item: authored('invoice') });
154+
vi.setSystemTime(new Date(T_READ_1));
155+
const before = await protocol.getMetaItemCached({ type: 'object', name: 'invoice' });
156+
157+
vi.setSystemTime(new Date(T_RESAVE));
158+
await protocol.saveMetaItem({ type: 'object', name: 'invoice', item: authored('invoice', 'Bill') });
159+
vi.setSystemTime(new Date(T_READ_2));
160+
const afterSave = await protocol.getMetaItemCached({ type: 'object', name: 'invoice' });
161+
162+
vi.setSystemTime(new Date(T_DRAFT));
163+
await protocol.saveMetaItem({ type: 'object', name: 'invoice', item: authored('invoice', 'Receipt'), mode: 'draft' });
164+
// A pending draft is not the served item: the active row's time stands.
165+
const withDraft = await protocol.getMetaItemCached({ type: 'object', name: 'invoice' });
166+
vi.setSystemTime(new Date(T_PUBLISH));
167+
await protocol.publishMetaItem({ type: 'object', name: 'invoice' });
168+
vi.setSystemTime(new Date(T_READ_3));
169+
const afterPublish = await protocol.getMetaItemCached({ type: 'object', name: 'invoice' });
170+
171+
expect(before.lastModified).toBe(T_SAVE);
172+
expect(afterSave.lastModified).toBe(T_RESAVE);
173+
expect(withDraft.lastModified).toBe(T_RESAVE);
174+
expect((afterPublish.data as { label?: string }).label).toBe('Receipt');
175+
expect(afterPublish.lastModified).toBe(T_PUBLISH);
176+
// CONTROL (§4): each change also moved the validator.
177+
expect(afterSave.etag).not.toEqual(before.etag);
178+
expect(afterPublish.etag).not.toEqual(afterSave.etag);
179+
});
180+
181+
it('§3 an item no stored row serves has no known change time: no lastModified key', async () => {
182+
const { engine } = makeStubEngine({ shipped: { ...authored('shipped', 'Shipped'), _packageId: 'com.example.code' } });
183+
const protocol = new ObjectStackProtocolImplementation(engine);
184+
vi.setSystemTime(new Date(T_READ_1));
185+
const read = await protocol.getMetaItemCached({ type: 'object', name: 'shipped' });
186+
187+
expect((read.data as { label?: string }).label).toBe('Shipped');
188+
expect(Object.keys(read)).not.toContain('lastModified');
189+
// CONTROL (§4): it still carries its validator and freshness rule.
190+
expect(read.etag?.value).toEqual(expect.any(String));
191+
expect(read.cacheControl).toEqual({ directives: ['private', 'no-cache'] });
192+
});
193+
194+
it('§4 the change time stays off the by-name read\'s published envelope', async () => {
195+
const { engine } = makeStubEngine();
196+
const protocol = new ObjectStackProtocolImplementation(engine);
197+
vi.setSystemTime(new Date(T_SAVE));
198+
await protocol.saveMetaItem({ type: 'object', name: 'invoice', item: authored('invoice') });
199+
const envelope = await protocol.getMetaItem({ type: 'object', name: 'invoice' });
200+
201+
expect(JSON.stringify(envelope)).not.toContain(T_SAVE);
202+
expect(Object.keys(envelope)).not.toContain('lastModified');
203+
});
204+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* # When the stored row a by-name read served last changed (#31196)
5+
*
6+
* The cached `/meta` item read (`getMetaItemCached`) answers `lastModified`,
7+
* which the REST door renders as `Last-Modified`. It used to be the time of
8+
* the READ (`new Date()`), so two reads of one unchanged item a second apart
9+
* answered two different values under one ETag, and the header carried no
10+
* information about the item.
11+
*
12+
* The item's own change time is known in exactly one place: the
13+
* `sys_metadata` row the by-name read ({@link readMetaItem}) adopts as the
14+
* item's content. Its `updated_at` is stamped by every write of that row, a
15+
* save and a publish alike (`SysMetadataRepository`). An item served from the
16+
* registry or the MetadataService (a code-shipped or file-loaded definition)
17+
* has no recorded change time, so it carries none, and the header is
18+
* omitted. ⛔ Never a stand-in: not the read time, not the boot time.
19+
*
20+
* The read records the row's time on the envelope it returns, and the cached
21+
* read asks {@link servedRowChangedAtOf}.
22+
*
23+
* ## Why a symbol, and why non-enumerable
24+
*
25+
* - **It never leaves the process.** `JSON.stringify` drops it, so the
26+
* by-name read's published envelope (`GetMetaItemResponseSchema`), its REST
27+
* and MCP answers, and every pin of them are unchanged.
28+
* - **It does not travel on a copy.** An object spread does not carry it, so
29+
* an envelope rebuilt elsewhere (the disabled-package withholding, a
30+
* wrapper) answers `undefined`, "no change time known", which omits the
31+
* header: the safe direction.
32+
*
33+
* `Symbol.for`, so a second evaluation of this module (the dual ESM/CJS
34+
* builds) reads what the first one wrote.
35+
*/
36+
37+
const SERVED_ROW_CHANGED_AT: unique symbol = Symbol.for('objectstack.metadata-protocol.servedRowChangedAt') as never;
38+
39+
/**
40+
* Record on `envelope` the change time of the stored row whose content it
41+
* serves (canonical ISO-8601 text). Mutates and returns `envelope`; with no
42+
* time (`undefined`) nothing is recorded.
43+
*
44+
* Only {@link readMetaItem} calls this, on its active-state answer.
45+
*/
46+
export function withServedRowChangedAt<T extends object>(envelope: T, changedAt: string | undefined): T {
47+
if (changedAt === undefined) return envelope;
48+
Object.defineProperty(envelope, SERVED_ROW_CHANGED_AT, {
49+
value: changedAt,
50+
enumerable: false,
51+
writable: false,
52+
configurable: true,
53+
});
54+
return envelope;
55+
}
56+
57+
/**
58+
* The change time {@link withServedRowChangedAt} recorded on a by-name read's
59+
* envelope, or `undefined` when the item was not served from a stored row, or
60+
* the envelope is a copy.
61+
*/
62+
export function servedRowChangedAtOf(envelope: unknown): string | undefined {
63+
if (envelope === null || typeof envelope !== 'object') return undefined;
64+
if (!Object.prototype.hasOwnProperty.call(envelope, SERVED_ROW_CHANGED_AT)) return undefined;
65+
const value = (envelope as { [SERVED_ROW_CHANGED_AT]?: unknown })[SERVED_ROW_CHANGED_AT];
66+
return typeof value === 'string' && value !== '' ? value : undefined;
67+
}

‎packages/metadata-protocol/src/sys-metadata-repository.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ import { draftChangedPaths, mergeDraftChanges, storedDraftChanges, type DraftCha
182182
* promises its callers the RAW value back, a contract this domain rewrite
183183
* would reverse.
184184
*/
185-
function canonicalIsoInstant(value: unknown): string | undefined {
185+
export function canonicalIsoInstant(value: unknown): string | undefined {
186186
if (value === null || value === undefined) return undefined;
187187
// [#14078] Total `Date` arm: an Invalid `Date` is the ONE `Date` shape
188188
// `toISOString()` refuses, and it is reachable from two live drivers. It

0 commit comments

Comments
 (0)