|
| 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