Skip to content

Commit e66efff

Browse files
committed
test(http-conformance): a real createHonoApp host holds PUT /meta's If-Match, If-None-Match and ?mode=draft
Four rows through app.request over a real ObjectStackProtocolImplementation (ObjectQLPlugin's built-in assembly on in-memory SQLite WASM): a stale If-Match, If-None-Match: * over an existing row, ?mode=draft, and the unguarded control. Each reads the store after the request. Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 28bff18 commit e66efff

1 file changed

Lines changed: 206 additions & 0 deletions

File tree

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#22221] Through a REAL `createHonoApp` host, `PUT /meta/:type/:name` honours
5+
* `If-Match`, `If-None-Match: *` and `?mode=draft` — the composed-host pin of
6+
* #22141's write preconditions.
7+
*
8+
* ## What this pins
9+
*
10+
* A host that mounts only `@objectstack/hono`'s `${prefix}/*` catch-all has no
11+
* `RestServer`: every `/meta` save goes catch-all → `HttpDispatcher.dispatch()`
12+
* → `handleMetadataRequest`'s `PUT` branch, which reads the precondition and
13+
* the lifecycle through `metaSaveRequestOptions` (`@objectstack/rest`). That
14+
* read sees the request only through what the catch-all hands `dispatch()`:
15+
*
16+
* - the two headers ride `{ request: c.req.raw }` — the raw Fetch `Request`,
17+
* read as `context.request.headers`;
18+
* - `?mode` rides the query argument the catch-all flattens from the URL.
19+
*
20+
* `packages/runtime`'s `meta-save-preconditions-parity.test.ts` holds the
21+
* dispatcher's half by repeating the catch-all's statements into `dispatch()`;
22+
* it cannot reach the adapter itself (see below). These rows go through
23+
* `app.request(...)`, so the adapter's hand-off is in the path: if the
24+
* catch-all stopped handing on the raw `Request`, dropped its headers or its
25+
* query, the rows here go red. Each row reads the STORE after the request, not
26+
* only the answer on the wire.
27+
*
28+
* The four rows are the card's: a stale `If-Match`, `If-None-Match: *` over an
29+
* existing row, `?mode=draft`, and the unguarded control.
30+
*
31+
* ## ⚠️ Why this lives HERE and not in `packages/adapters/hono` or `packages/runtime`
32+
*
33+
* `packages/adapters/hono/vitest.config.ts` aliases `@objectstack/runtime` to a
34+
* stub, and `packages/runtime` cannot depend on `@objectstack/hono` (that
35+
* package depends on it). This package boots the two for real:
36+
* `@objectstack/hono` is aliased to this checkout's adapter SOURCE (the
37+
* subject), and `@objectstack/runtime` resolves through its `dist/` (a
38+
* ledgered pair in `scripts/check-test-source-alias.mjs`), so a runtime change
39+
* reaches these rows once that package is rebuilt, as CI builds it.
40+
*
41+
* ## The rig
42+
*
43+
* The read-side twin (`hono-meta-item-read-gate.conformance.test.ts`) answers
44+
* reads from a stub protocol, which keeps no version and no draft, so a save
45+
* there could not be refused or staged. Here the protocol is the real one:
46+
* `ObjectQLPlugin`'s built-in assembly registers `ObjectStackProtocolImplementation`
47+
* and the `sys_metadata*` objects, over an in-memory SQLite (pure-JS WASM)
48+
* driver handed to it as a `driver.*` service. The one stubbed seam is the
49+
* dispatcher's identity step, as in the read-side twin: every request is the
50+
* same author holding `manage_metadata`, so no row depends on the request for
51+
* who the caller is.
52+
*/
53+
54+
import { describe, it, expect, vi, afterEach } from 'vitest';
55+
import { LiteKernel } from '@objectstack/core';
56+
import { ObjectQLPlugin } from '@objectstack/objectql';
57+
import type { ObjectQL } from '@objectstack/objectql';
58+
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
59+
import { HttpDispatcher } from '@objectstack/runtime';
60+
import { createHonoApp } from '@objectstack/hono';
61+
62+
const PREFIX = '/api/v1';
63+
/** An author: `manage_metadata` saves, and reads drafts. */
64+
const AUTHOR = { userId: 'u_author', isSystem: false, systemPermissions: ['manage_metadata'] };
65+
66+
const TASK = {
67+
name: 'task',
68+
label: 'Task',
69+
fields: {
70+
name: { name: 'name', type: 'text' as const, label: 'Name' },
71+
},
72+
};
73+
74+
const view = (name: string, label: string) => ({
75+
name,
76+
label,
77+
object: 'task',
78+
viewKind: 'list',
79+
columns: [{ field: 'name', label: 'Name' }],
80+
});
81+
82+
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v));
83+
84+
const liveKernels: LiteKernel[] = [];
85+
afterEach(async () => {
86+
vi.restoreAllMocks();
87+
while (liveKernels.length) {
88+
const kernel = liveKernels.pop();
89+
await Promise.race([
90+
kernel?.shutdown(),
91+
new Promise<void>((resolve) => setTimeout(resolve, 10_000)),
92+
]);
93+
}
94+
});
95+
96+
interface Answer { status: number; body: any }
97+
interface SaveOptions { headers?: Record<string, string>; query?: Record<string, string> }
98+
99+
/** The real store, the real protocol over it, and the REAL `createHonoApp` over both. */
100+
async function boot() {
101+
const driver = new SqliteWasmDriver({ filename: ':memory:' });
102+
await driver.connect();
103+
104+
const kernel = new LiteKernel();
105+
liveKernels.push(kernel);
106+
kernel.use({
107+
name: 'test-meta-save-driver',
108+
version: '1.0.0',
109+
init: (c: any) => { c.registerService('driver.memory', driver); },
110+
} as any);
111+
kernel.use(new ObjectQLPlugin());
112+
await kernel.bootstrap();
113+
114+
const ql = kernel.getService<ObjectQL>('objectql');
115+
ql.registerObject(TASK as any);
116+
await ql.syncObjectSchema('task');
117+
118+
vi.spyOn(HttpDispatcher.prototype as any, 'timedResolveExecutionContext')
119+
.mockImplementation(async () => clone(AUTHOR));
120+
121+
const app = createHonoApp({ kernel: kernel as any, prefix: PREFIX, cors: false });
122+
123+
const send = async (method: 'PUT' | 'GET', name: string, { headers = {}, query = {} }: SaveOptions, body?: unknown): Promise<Answer> => {
124+
const url = new URL(`http://localhost${PREFIX}/meta/view/${name}`);
125+
for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v);
126+
const res = await app.request(url.toString(), {
127+
method,
128+
headers: body === undefined ? headers : { 'content-type': 'application/json', ...headers },
129+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
130+
});
131+
return { status: res.status, body: JSON.parse(await res.text()) };
132+
};
133+
134+
return {
135+
save: (name: string, label: string, opts: SaveOptions = {}) => send('PUT', name, opts, view(name, label)),
136+
read: (name: string, query: Record<string, string> = {}) => send('GET', name, { query }),
137+
/** The stored labels of a view, by lifecycle — the store, not the door's word for it. */
138+
stored: async (name: string, state: 'active' | 'draft'): Promise<string[]> => {
139+
const rows = await ql.find('sys_metadata', { where: { type: 'view', name, state } });
140+
return (rows ?? []).map((r: any) => JSON.parse(String(r.metadata)).label as string);
141+
},
142+
};
143+
}
144+
145+
/** A save the host accepted: `200`, `success: true`, and the receipt. */
146+
const accepted = (a: Answer) => {
147+
expect({ status: a.status, success: a.body?.success }, JSON.stringify(a.body)).toEqual({ status: 200, success: true });
148+
return a.body.data;
149+
};
150+
151+
/** A save the host refused: the ADR-0112 envelope, status and nested `code` both. */
152+
const refused = (a: Answer) => ({ status: a.status, success: a.body?.success, code: a.body?.error?.code });
153+
154+
describe('[#22221] a real createHonoApp host: PUT /meta/:type/:name honours If-Match, If-None-Match and ?mode=draft', () => {
155+
it('control: an unguarded save writes the active row, and the last writer wins', async () => {
156+
const host = await boot();
157+
expect(accepted(await host.save('case_grid', 'v1')).state).toBe('active');
158+
expect(accepted(await host.save('case_grid', 'v2')).state).toBe('active');
159+
160+
expect(await host.stored('case_grid', 'active')).toEqual(['v2']);
161+
expect(await host.stored('case_grid', 'draft')).toEqual([]);
162+
}, 60_000);
163+
164+
it('a stale If-Match: 409 METADATA_CONFLICT, the stored row unchanged; the current token still saves', async () => {
165+
const host = await boot();
166+
const first = accepted(await host.save('case_grid', 'v1'));
167+
const second = accepted(await host.save('case_grid', 'v2'));
168+
expect(second.version).not.toBe(first.version);
169+
170+
const stale = await host.save('case_grid', 'v3 over a stale read', { headers: { 'If-Match': first.version } });
171+
expect(refused(stale)).toEqual({ status: 409, success: false, code: 'METADATA_CONFLICT' });
172+
expect(await host.stored('case_grid', 'active')).toEqual(['v2']);
173+
174+
// Preservation: the token the last receipt served still pins a save.
175+
accepted(await host.save('case_grid', 'v3', { headers: { 'If-Match': second.version } }));
176+
expect(await host.stored('case_grid', 'active')).toEqual(['v3']);
177+
}, 60_000);
178+
179+
it('If-None-Match: * over an existing row: 409 METADATA_CONFLICT, the stored row unchanged', async () => {
180+
const host = await boot();
181+
accepted(await host.save('fresh_grid', 'first', { headers: { 'If-None-Match': '*' } }));
182+
expect(await host.stored('fresh_grid', 'active')).toEqual(['first']);
183+
184+
const again = await host.save('fresh_grid', 'second', { headers: { 'If-None-Match': '*' } });
185+
expect(refused(again)).toEqual({ status: 409, success: false, code: 'METADATA_CONFLICT' });
186+
expect(await host.stored('fresh_grid', 'active')).toEqual(['first']);
187+
}, 60_000);
188+
189+
it('?mode=draft stages a draft: the active row unchanged, the draft read back through ?state=draft', async () => {
190+
const host = await boot();
191+
accepted(await host.save('case_grid', 'live'));
192+
193+
expect(accepted(await host.save('case_grid', 'staged', { query: { mode: 'draft' } })).state).toBe('draft');
194+
expect(await host.stored('case_grid', 'active')).toEqual(['live']);
195+
expect(await host.stored('case_grid', 'draft')).toEqual(['staged']);
196+
197+
// The doors the platform reads it through, on the same host: the draft
198+
// read answers the staged row, the plain read still the active one.
199+
const draftRead = await host.read('case_grid', { state: 'draft' });
200+
expect(draftRead.status, JSON.stringify(draftRead.body)).toBe(200);
201+
expect(draftRead.body?.data?.item?.label).toBe('staged');
202+
const plainRead = await host.read('case_grid');
203+
expect(plainRead.status, JSON.stringify(plainRead.body)).toBe(200);
204+
expect(plainRead.body?.data?.item?.label).toBe('live');
205+
}, 60_000);
206+
});

0 commit comments

Comments
 (0)