Repository navigation
fix(metadata-protocol): the cached /meta item read answers the item's own change time as Last-Modified, never the read time - #31248
Conversation
… row's change time as lastModified, never the read time The cached by-name read stamped `lastModified` with `new Date()`, so two reads of one unchanged item answered two Last-Modified values under one ETag. The by-name read now records, off its published envelope (a non-enumerable symbol), the `updated_at` of the stored row whose content it adopts; the cached read answers that, and an item no stored row serves answers no `lastModified` key, so the REST door sends no header. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5p9eu5KqN5GP7JZAGe
… and add the metadata-protocol changeset Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5p9eu5KqN5GP7JZAGe
…the caller's limit, and the engine-double ledger records it Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5p9eu5KqN5GP7JZAGe
…, and the where double refuses combinators Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5p9eu5KqN5GP7JZAGe
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin da3ef679fbcdaf369f0f08285eb49f5ecdd14f42 && git checkout da3ef679fbcdaf369f0f08285eb49f5ecdd14f42
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0d0c377beb988b990e77aa4763fd5e4b429d8de4 7ff2b9e528edafe5baf30284f84939be5127e926 && git checkout -B drift-repro 0d0c377beb988b990e77aa4763fd5e4b429d8de4 && git merge --no-ff 7ff2b9e528edafe5baf30284f84939be5127e926
node scripts/docs-audit/affected-docs.mjs --json 0d0c377beb988b990e77aa4763fd5e4b429d8de4
|
|
domain:server#5 · os-project-manager · session_01FEkQ5o8PE7ZtVjcor51wBZ 维护者速读
席位意见:接受。 席位核对 — ACCEPT ·
|
Requested by Leehom · project thread
Fixes #31196
Clause-②: no
Metadata-protocol: none
维护者速读
GET /api/v1/meta/:type/:name)里,Last-Modified不再填「这次读取的时间」。条目来自sys_metadata存储行时,填该行的updated_at(最近一次保存或发布的时间);条目来自代码或文件、平台不知道它何时改过时,不发这个头。Last-Modified不一样而 ETag 相同,这个头在说假话,还让 Queue-flake anchor: src/domains/mcp-object-schema-mask.test.ts #31171 的测试在合并队列里抖过两次。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) answeredlastModified: new Date().toISOString(), the time of the read. It now answers the change time of the storedsys_metadatarow the item is served from, and nolastModifiedkey when no stored row serves it. The REST door already rendersLast-Modifiedonly when the key is present (meta-item-read-routes.ts, unchanged), so a code-shipped item answers no header.readMetaItem,protocol-meta-item-reads.ts) holds the row it adopts as the item's content. It recordscanonicalIsoInstant(row.updated_at ?? row.created_at)on its active-state envelope as a non-enumerableSymbol.forkey (served-row-changed-at.ts, thestored-row-binding.tspattern).JSON.stringifydrops it and a spread does not copy it, so the publishedGetMetaItemResponseSchemaenvelope, its REST and MCP answers, and everytoEqualpin are unchanged. The cached read asksservedRowChangedAtOf(result).canonicalIsoInstantinsys-metadata-repository.tsis now exported (module-internal;index.tsdoes not re-export it), so the read uses the repository's one spelling of a driver timestamp..changeset/31196-cached-meta-item-last-modified.md:@objectstack/metadata-protocolpatch.@objectstack/restgains a test only.Tests
packages/metadata-protocol/src/protocol.cached-item-last-modified.test.ts(real protocol, stubsys_metadatastore, fakeDatemoved minutes between steps):cacheControl['private','no-cache'].lastModifiedkey; CONTROL: it keeps its ETag andcacheControl.packages/rest/src/meta-item-cached-last-modified.test.ts(the door, protocol double): renders the protocol's time asSat, 07 Nov 2026 08:00:00 GMTand the same on a second read; sends noLast-Modifiedwhen the key is absent while ETag andCache-Controlstay;If-Modified-Sinceanswers 200 with the body, with or without a time.Ablation (os-dev, via
node scripts/ablation-replace.mjs, the test resolves the protocol fromsrc/): anchor...(lastModified !== undefined ? { lastModified } : {}),replaced bylastModified: new Date().toISOString(),(anchor 1 → 0, replacement 0 → 1, blob c29eebfc → c033ddc6), then the pin file:Ablation red: 3 failed | 1 passed (§4 stays green, as predicted: it reads the by-name envelope, not the cached answer)
Acceptance notes
origin/main66bf5b4. The order's hypothesis holds: the producer stamped the read time and the REST door only rendered it. The door needed no change.getMetaItemCached: its only input isgetMetaItem's envelope, whoseversionis a hash digest of the stored head (readVersionToken→receiptVersion), not a time. The row is in hand insidereadMetaItem, so the file surface grew past the claim's byprotocol-meta-item-reads.ts, the newserved-row-changed-at.ts, a one-wordexportinsys-metadata-repository.ts, and the engine-double ledger row for the new test (scripts/engine-double-contract.pinned.json, written bycheck-engine-double-contract.mjs --write).updated_at, whichSysMetadataRepositorystamps on every write of the row, a save and a publish alike (§2 measures both),created_atfor 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.extendfields from installed packages, the artifact's lock envelope). Installing a package that changes them moves the ETag but not thisLast-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 ascacheRequest.ifModifiedSince; the protocol never reads that member, and the door's own 304 comparesIf-None-Matchonly. So no request is answered 304 from a time, before or after this change; the REST pin records it.Last-Modifiedis whole seconds (toUTCString()), so two writes within one second share a header value; the ETag still separates them.packages/runtime/src/domains/mcp-object-schema-mask.test.ts(PR test(runtime): hold clock-derived header values fixed in the /meta action-entry wire comparison #31195) maskslast-modifiedwith a comment saying the protocol stamps the read time. After this PR that sentence is false forLast-Modified(theDateheader half still holds); the masking itself stays harmless. Outside the claimed surface.Verification
Head
7ff2b9e5.origin/mainwas 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 otherpackages/metadata-protocoltest 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'stscincludessrc/**/*, the new test listed by--listFiles; rest runscheck:test-typecheck, 0 errors).node --stack-size=4000 node_modules/eslint/bin/eslint.js packages/metadata-protocol packages/rest --no-inline-config, exit 0.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstacknamed 69 families for this diff; all 69 ran on7ff2b9e5, exit 0 each.--ran: "69 derived famil(ies) accounted for — 69 run, 0 NOT-MEASURED".check-plugin-teardown-shape --self-testneededgit fetch --depth=1 origin 621a4876on this shallow clone first.check:engine-double-contract(ledger rows written with--write),check:objectql-double-limit(thefinddouble now honourslimit),check:where-matcher(the matcher refuses$combinators),check:test-source-alias(module-top preloads:./lint-root-preload.testkit.jsand@objectstack/objectql).Generated by Claude Code