From f59f3facec8228aab63dc25f74d00c78293d8408 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 18:17:25 +0000 Subject: [PATCH 1/4] fix(metadata-protocol): the cached /meta item read answers the stored 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 Claude-Session: https://claude.ai/code/session_01FEkQ5p9eu5KqN5GP7JZAGe --- .../src/protocol-meta-item-reads.ts | 16 +- .../src/protocol-search-and-cache.ts | 12 +- ...protocol.cached-item-last-modified.test.ts | 199 ++++++++++++++++++ .../src/served-row-changed-at.ts | 67 ++++++ .../src/sys-metadata-repository.ts | 2 +- 5 files changed, 292 insertions(+), 4 deletions(-) create mode 100644 packages/metadata-protocol/src/protocol.cached-item-last-modified.test.ts create mode 100644 packages/metadata-protocol/src/served-row-changed-at.ts diff --git a/packages/metadata-protocol/src/protocol-meta-item-reads.ts b/packages/metadata-protocol/src/protocol-meta-item-reads.ts index aa0b1d8bc82..fb96a7ddd7f 100644 --- a/packages/metadata-protocol/src/protocol-meta-item-reads.ts +++ b/packages/metadata-protocol/src/protocol-meta-item-reads.ts @@ -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, request: { @@ -326,6 +328,10 @@ export async function readMetaItem(host: Pick; // [#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 @@ -389,6 +395,9 @@ export async function readMetaItem(host: Pick; + +/** [#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 = {}): boolean { + for (const [k, v] of Object.entries(where)) { + 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 = {}) { + const rows: Row[] = []; + let nextId = 0; + const engine: any = { + async findOne(t: string, opts: { where: Record }) { + 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 }) { + if (t !== 'sys_metadata') return []; + return rows.filter((r) => matches(r, opts?.where)); + }, + async count(t: string, opts?: { where?: Record }) { + 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 }) { + 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) { + assertEngineDeleteDispatch(opts); + return { deleted: 0 }; + }, + async transaction(cb: (ctx: unknown, info: { owned: boolean }) => Promise): Promise { + 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'); + }); +}); diff --git a/packages/metadata-protocol/src/served-row-changed-at.ts b/packages/metadata-protocol/src/served-row-changed-at.ts new file mode 100644 index 00000000000..42f61b22933 --- /dev/null +++ b/packages/metadata-protocol/src/served-row-changed-at.ts @@ -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(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; +} diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index dc9ba82f943..1b0630b4e88 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -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 From 565bea3808efeb1e10c9d308cc1e212b04952448 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 18:22:06 +0000 Subject: [PATCH 2/4] test(rest): pin the cached /meta item door's Last-Modified rendering, and add the metadata-protocol changeset Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5p9eu5KqN5GP7JZAGe --- .../31196-cached-meta-item-last-modified.md | 14 +++ .../meta-item-cached-last-modified.test.ts | 102 ++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 .changeset/31196-cached-meta-item-last-modified.md create mode 100644 packages/rest/src/meta-item-cached-last-modified.test.ts diff --git a/.changeset/31196-cached-meta-item-last-modified.md b/.changeset/31196-cached-meta-item-last-modified.md new file mode 100644 index 00000000000..7321070d1c1 --- /dev/null +++ b/.changeset/31196-cached-meta-item-last-modified.md @@ -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. diff --git a/packages/rest/src/meta-item-cached-last-modified.test.ts b/packages/rest/src/meta-item-cached-last-modified.test.ts new file mode 100644 index 00000000000..a4d33d31e28 --- /dev/null +++ b/packages/rest/src/meta-item-cached-last-modified.test.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #31196 — the cached `GET /meta/:type/:name` sends `Last-Modified` only as the +// protocol's `lastModified`: the item's own change time (the stored row's +// `updated_at`), or nothing when the protocol knows none. The producer half — +// a save and a publish move it, a read never does, a code-shipped item has +// none — is pinned on the real producer in `metadata-protocol`'s +// `protocol.cached-item-last-modified.test.ts`. +// +// This door renders the key and judges nothing from it: a conditional read is +// judged by the ETag alone, so `If-Modified-Since` never answers a `304`, with +// or without a time to compare against. + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server'; + +const ANON_API = { api: { requireAuth: false } }; + +const PAGE = { name: 'home_page', label: 'Home', type: 'app', regions: [] }; + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeRes() { + const res: any = { statusCode: 200, body: undefined, headers: {} as Record }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.header = vi.fn((k: string, v: string) => { res.headers[k] = v; return res; }); + res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); res.send = vi.fn(); + return res; +} + +/** The producer's answer: `lastModified` when a stored row serves the item, no key otherwise. */ +function setup(lastModified: string | undefined) { + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ + version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' }, + }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue([]), + getMetaItem: vi.fn(), + findData: vi.fn().mockResolvedValue([]), + getMetaItemCached: vi.fn(async () => ({ + data: JSON.parse(JSON.stringify(PAGE)), + etag: { value: 'etag_page', weak: false }, + ...(lastModified !== undefined ? { lastModified } : {}), + cacheControl: { directives: ['private', 'no-cache'] }, + notModified: false, + })), + }; + const rest = new RestServer(mockServer() as any, protocol as any, ANON_API as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: [] }); + rest.registerRoutes(); + return { rest, protocol }; +} + +async function getItem(rest: any, headers: Record = {}) { + const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/meta/:type/:name'); + if (!route) throw new Error('GET /api/v1/meta/:type/:name route not registered'); + const res = makeRes(); + await route.handler({ method: 'GET', params: { type: 'page', name: 'home_page' }, query: {}, body: {}, headers }, res); + return res; +} + +const SAVED_AT = '2026-11-07T08:00:00.000Z'; + +describe('[#31196] the cached `GET /meta/:type/:name` sends the item\'s own `Last-Modified`, or none', () => { + it('renders the protocol\'s change time, the same on every read of the unchanged item', async () => { + const { rest, protocol } = setup(SAVED_AT); + const first = await getItem(rest); + const second = await getItem(rest); + + expect(protocol.getMetaItemCached).toHaveBeenCalledTimes(2); + expect(first.statusCode).toBe(200); + expect(first.headers['Last-Modified']).toBe('Sat, 07 Nov 2026 08:00:00 GMT'); + expect(second.headers['Last-Modified']).toBe(first.headers['Last-Modified']); + // CONTROL: the validator and the freshness rule. + expect(first.headers.ETag).toBe('"etag_page"'); + expect(first.headers['Cache-Control']).toBe('private, no-cache'); + }); + + it('an item with no known change time answers no `Last-Modified` header — and keeps its ETag', async () => { + const res = await getItem(setup(undefined).rest); + + expect(res.statusCode).toBe(200); + expect(Object.keys(res.headers)).not.toContain('Last-Modified'); + expect(res.headers.ETag).toBe('"etag_page"'); + expect(res.headers['Cache-Control']).toBe('private, no-cache'); + }); + + it('`If-Modified-Since` never answers a 304, with or without a time to compare', async () => { + for (const lastModified of [SAVED_AT, undefined]) { + const res = await getItem(setup(lastModified).rest, { 'if-modified-since': 'Sun, 08 Nov 2026 00:00:00 GMT' }); + expect(res.statusCode).toBe(200); + expect(res.body.item.name).toBe('home_page'); + } + }); +}); From a6bb0f19925ed731acd60bbef8a297f165a99876 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 18:39:58 +0000 Subject: [PATCH 3/4] test(metadata-protocol): the Last-Modified pin's find double honours the caller's limit, and the engine-double ledger records it Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5p9eu5KqN5GP7JZAGe --- .../protocol.cached-item-last-modified.test.ts | 5 +++-- scripts/engine-double-contract.pinned.json | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.cached-item-last-modified.test.ts b/packages/metadata-protocol/src/protocol.cached-item-last-modified.test.ts index b706011b07b..fcf031517c2 100644 --- a/packages/metadata-protocol/src/protocol.cached-item-last-modified.test.ts +++ b/packages/metadata-protocol/src/protocol.cached-item-last-modified.test.ts @@ -55,9 +55,10 @@ function makeStubEngine(code: Record = {}) { if (t !== 'sys_metadata') return null; return rows.find((r) => matches(r, opts.where)) ?? null; }, - async find(t: string, opts?: { where?: Record }) { + async find(t: string, opts?: { where?: Record; limit?: number }) { if (t !== 'sys_metadata') return []; - return rows.filter((r) => matches(r, opts?.where)); + 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 }) { if (t !== 'sys_metadata') return 0; diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 93641eefba0..fb7ca3012a4 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -556,6 +556,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.cached-item-last-modified.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.cached-item-last-modified.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.cached-item-last-modified.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.capability-write-door.test.ts", "verb": "delete", From 7ff2b9e528edafe5baf30284f84939be5127e926 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 19:14:00 +0000 Subject: [PATCH 4/4] test: the Last-Modified pins pay their lazy first loads at collection, and the where double refuses combinators Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5p9eu5KqN5GP7JZAGe --- .../src/protocol.cached-item-last-modified.test.ts | 4 ++++ packages/rest/src/meta-item-cached-last-modified.test.ts | 3 +++ 2 files changed, 7 insertions(+) diff --git a/packages/metadata-protocol/src/protocol.cached-item-last-modified.test.ts b/packages/metadata-protocol/src/protocol.cached-item-last-modified.test.ts index fcf031517c2..4981135a7b2 100644 --- a/packages/metadata-protocol/src/protocol.cached-item-last-modified.test.ts +++ b/packages/metadata-protocol/src/protocol.cached-item-last-modified.test.ts @@ -21,6 +21,8 @@ * `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, @@ -39,6 +41,8 @@ const whereValueMatches = (actual: unknown, want: unknown): boolean => function matches(r: Row, where: Record = {}): 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; } diff --git a/packages/rest/src/meta-item-cached-last-modified.test.ts b/packages/rest/src/meta-item-cached-last-modified.test.ts index a4d33d31e28..2d13c31fde5 100644 --- a/packages/rest/src/meta-item-cached-last-modified.test.ts +++ b/packages/rest/src/meta-item-cached-last-modified.test.ts @@ -11,6 +11,9 @@ // judged by the ETag alone, so `If-Modified-Since` never answers a `304`, with // or without a time to compare against. +// Pays `@objectstack/objectql`'s first load at collection, never inside a clocked case +// (the route modules import it lazily). +import '@objectstack/objectql'; import { describe, it, expect, vi } from 'vitest'; import { RestServer } from './rest-server';