From 2e2af92f2349f1fa3b4ae08ad4ad726f2a461275 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 18:20:23 +0000 Subject: [PATCH 1/2] fix(runtime): `/meta/:type/:name/published` resolves from the published store, not the code snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An item published at runtime — authored as an ADR-0027 draft and promoted via `POST /packages/:id/publish-drafts` — answered 404 on this route while the ordinary read `GET /meta/:type/:name` served it. The route and the publish path shared no store: the write flips the artifact's `sys_metadata` row `state:'draft' → 'active'` (that route's own comment notes it has "no metadata service dependency"), and the read resolved only through `metadataService.getPublished`, which reads the `publishedDefinition` key `MetadataManager.publishPackage` writes into its own in-memory registry. ADR-0027 (E)(5) defines sealing a publish as exactly that `draft → active` flip, `SysMetadataRepository` names `'active'` "the published, live overlay", and ADR-0033 §2 — the ADR this route cites — routes every authoring write into that same ADR-0027 draft. The `active` overlay row is therefore authoritative for "what is published", and the route now consults it first. Read through `getMetaItemLayered`, whose overlay layer is a strict `state:'active'` lookup reported separately from the code layer: a draft is never served, and a null overlay falls through to the untouched `getPublished` path so a code-published item answers the same bytes it always did. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011Q72AE6sKzpP8Z4o1RM7xy --- .../published-route-reads-published-store.md | 49 +++ .../meta-published-runtime-publish.test.ts | 315 ++++++++++++++++++ packages/runtime/src/domains/meta.ts | 49 +++ 3 files changed, 413 insertions(+) create mode 100644 .changeset/published-route-reads-published-store.md create mode 100644 packages/runtime/src/domains/meta-published-runtime-publish.test.ts diff --git a/.changeset/published-route-reads-published-store.md b/.changeset/published-route-reads-published-store.md new file mode 100644 index 0000000000..7b47f4a915 --- /dev/null +++ b/.changeset/published-route-reads-published-store.md @@ -0,0 +1,49 @@ +--- +'@objectstack/runtime': patch +--- + +`GET /meta/:type/:name/published`: resolve from the published store, not the code/package snapshot + +An item published at runtime — authored as an ADR-0027 draft and promoted via +`POST /packages/:id/publish-drafts` — answered `404` on this route, while the +ordinary read `GET /meta/:type/:name` served it. The route and the publish path +shared no store: + +- **the write** flips the artifact's `sys_metadata` row `state:'draft' → + 'active'` (`publishPackageDrafts` / `promoteDraft`), and the dispatcher's own + comment on that route notes it has "no metadata service dependency"; +- **the read** resolved only through `metadataService.getPublished`, which reads + the row-local `publishedDefinition` key that `MetadataManager.publishPackage` + writes into its own in-memory registry — the ADR-0016-era package publish. + +So the 404 was a false statement about an item that IS published. ADR-0027 (E)(5) +defines sealing a publish as exactly that `draft → active` flip; +`SysMetadataRepository` names `'active'` "the published, live overlay"; and +ADR-0033 §2 — the ADR this route cites — routes every authoring write into that +same ADR-0027 draft. The `active` overlay row is therefore the authoritative +answer to "what is published", and this route now consults it first. + +The overlay is read through `getMetaItemLayered`, whose overlay layer is a +strict `state:'active'` lookup (org-scoped first, then env-wide, with the +ADR-0048 package preference) reported separately from the code layer. That +separation is what the fix rests on: + +- a **runtime-published** item is served, and served the published body; +- a **draft-only** item is still `404` — the overlay lookup never reads a draft, + so a pending edit is not served as published; +- a **code-published** item is untouched: a null overlay is positively "no + runtime-published row" and falls through to the existing `getPublished` path, + which answers the same bytes it always did. The broader `getMetaItem` would + not do — it folds the code layer into its own answer, so the route could no + longer tell the two stores apart, and a code-published item would be served + its raw stored envelope instead of its `publishedDefinition`. + +Unchanged on purpose: `404` on this route continues to mean "no such item" +rather than "exists but unpublished" — an existing item that was never published +still answers `200` with its current definition, which is `getPublished`'s +documented fallback and a different fact from absence. + +The identical divergence on the `packages/rest` transport +(`GET /api/v1/meta/:type/:name/published`, which resolves the same optional +`getPublished` member) is NOT addressed here — that surface has a different +owner. diff --git a/packages/runtime/src/domains/meta-published-runtime-publish.test.ts b/packages/runtime/src/domains/meta-published-runtime-publish.test.ts new file mode 100644 index 0000000000..af7639c855 --- /dev/null +++ b/packages/runtime/src/domains/meta-published-runtime-publish.test.ts @@ -0,0 +1,315 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#8031] `GET /meta/:type/:name/published` resolves from the AUTHORITATIVE + * published store — the `state:'active'` `sys_metadata` overlay row. + * + * Two publish lifecycles exist in this repo, and they write to different places: + * + * - **Package publish** (ADR-0016 era) — `MetadataManager.publishPackage` + * snapshots each item's body into the row-local `publishedDefinition` + * envelope key, in the manager's own in-memory registry. + * - **Runtime draft publish** (ADR-0027 (E)(5)) — `publishPackageDrafts` / + * `promoteDraft` flips the artifact's `sys_metadata` row from + * `state:'draft'` to `state:'active'`. ADR-0027 (E)(5) defines sealing a + * publish as exactly that flip; `SysMetadataRepository` names `'active'` + * "the published, live overlay"; and ADR-0033 §2 — the ADR this route + * cites — routes EVERY authoring write into that same ADR-0027 draft. + * + * The route used to resolve exclusively through the FIRST of those, while the + * dispatcher's own `publish-drafts` comment states that path has "no metadata + * service dependency" — so read and write shared no store, and a + * runtime-published item answered 404. + * + * These tests exercise the REAL protocol implementation over a faithful stub + * engine and the REAL `MetadataManager` — no mock stands in for either store — + * so what they measure is the wiring, not a stub's opinion of it. + */ + +import { describe, it, expect } from 'vitest'; +import { MetadataManager } from '@objectstack/metadata'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +interface Row { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; + checksum?: string; + version?: number; +} + +/** ADR-0048 overlay key — an env-wide draft and an active row coexist. */ +function keyOf(w: Record) { + return `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; +} + +function matchesWhere(r: Row, where: Record): boolean { + for (const [k, v] of Object.entries(where)) { + if (k === '$or') { + const clauses = v as Array>; + if (!clauses.some((c) => matchesWhere(r, c))) return false; + continue; + } + if (v === undefined) continue; + if ((r as any)[k] !== v) return false; + } + return true; +} + +/** Minimal multi-table stub engine — honours `$or` and `organization_id IS NULL`. */ +function makeStubEngine() { + const rows = new Map(); + let nextId = 0; + + const findRow = (w: Record): { key: string; row: Row } | null => { + if (w.id !== undefined) { + for (const [k, r] of rows) if (r.id === w.id) return { key: k, row: r }; + return null; + } + if (w.package_id !== undefined) { + const k = keyOf(w); + const r = rows.get(k); + return r ? { key: k, row: r } : null; + } + for (const [k, r] of rows) if (matchesWhere(r, w)) return { key: k, row: r }; + return null; + }; + + const engine: any = { + async findOne(table: string, opts: { where: Record }) { + if (table === 'sys_metadata_history') return null; + return findRow(opts.where)?.row ?? null; + }, + async find(table: string, opts: { where: Record }) { + if (table === 'sys_metadata_history') return []; + return Array.from(rows.values()).filter((r) => matchesWhere(r, opts.where)); + }, + async insert(table: string, data: Record) { + if (table === 'sys_metadata_audit') return { id: 'audit_skip' }; + if (table === 'sys_metadata_history') { + nextId += 1; + return { id: `h_${nextId}` }; + } + nextId += 1; + const row = { id: `r_${nextId}`, ...(data as any) } as Row; + rows.set(keyOf(data), row); + return { id: row.id }; + }, + async update(_t: string, data: Record, opts: { where: Record }) { + const found = findRow(opts.where); + if (!found) return { id: null }; + const merged = { ...found.row, ...(data as any) }; + rows.delete(found.key); + rows.set(keyOf(merged), merged); + return { id: found.row.id }; + }, + async delete(_t: string, opts: { where: Record }) { + const found = findRow(opts.where); + if (!found) return { deleted: 0 }; + rows.delete(found.key); + return { deleted: 1 }; + }, + async transaction(cb: (ctx: any, info: { owned: boolean }) => Promise): Promise { + return cb(undefined, { owned: true }); + }, + registry: { + registerItem: () => {}, + registerObject: () => {}, + getItem: () => undefined, + getPackage: () => undefined, + }, + }; + return { engine, rows }; +} + +/** + * The protocol as a real kernel wires it: the `metadata` slot is reachable + * through its services registry, so the protocol's CODE layer really can + * resolve. Without this the code-published fixture below would pass no matter + * which primitive this route used — the code layer would be unreachable and + * every arm would fall through to `getPublished` alike. + */ +function makeProtocol(engine: any, metadata: unknown) { + return new ObjectStackProtocolImplementation( + engine, + () => new Map([['metadata', metadata]]), + ); +} + +const RUNTIME_BODY = { + name: 'proj_task', + label: 'Project Task', + fields: { + title: { type: 'text', label: 'Title' }, + done: { type: 'boolean', label: 'Done' }, + }, +}; + +/** A DISTINCT body, so "which store answered" is readable off the response. */ +const CODE_BODY = { + name: 'code_widget', + label: 'Code Widget', + fields: { sku: { type: 'text', label: 'SKU' } }, +}; + +function make(services: Record) { + const kernel = { + getServiceAsync: async (name: string) => services[name] ?? null, + getService: (name: string) => services[name] ?? null, + context: { getService: (name: string) => services[name] ?? null }, + } as any; + return new HttpDispatcher(kernel); +} + +const ctx = (): any => ({ + request: {}, + environmentId: 'platform', + executionContext: { userId: 'u1', systemPermissions: ['manage_metadata'] }, +}); + +/** + * The dispatcher result's `response` is optional on the type; every call below + * is a handled route, so narrow once here rather than at each assertion. + */ +function responseOf(result: { handled: boolean; response?: any }) { + expect(result.response).toBeDefined(); + return result.response!; +} + +/** Author a draft and publish it — the ADR-0027 (E)(5) runtime path. */ +async function runtimePublish(protocol: any, name: string, body: unknown) { + await protocol.saveMetaItem({ + type: 'object', + name, + item: body, + packageId: 'app.projects', + mode: 'draft', + }); + return protocol.publishPackageDrafts({ packageId: 'app.projects' }); +} + +describe('#8031 — GET /meta/:type/:name/published resolves from the published store', () => { + it('serves a RUNTIME-published item, and serves the published body', async () => { + const { engine, rows } = makeStubEngine(); + const metadata = new MetadataManager({}); + const protocol = makeProtocol(engine, metadata); + + await runtimePublish(protocol, 'proj_task', RUNTIME_BODY); + + // ANTI-VACUITY: the publish really landed — an `active` row exists and + // carries the authored body. Without this, a 404 could merely mean + // "the write never happened". + const active = Array.from(rows.values()).filter((r) => r.state === 'active'); + expect(active).toHaveLength(1); + expect(JSON.parse(active[0]!.metadata)).toMatchObject({ label: 'Project Task' }); + + const response = responseOf(await make({ protocol, metadata }) + .handleMetadata('/object/proj_task/published', ctx(), 'GET')); + + expect(response.status).toBe(200); + // Read a value from INSIDE the body, so serving some other document + // (an envelope, a stub) fails on the value rather than passing on a + // key that is merely present. + expect(response.body.data).toMatchObject({ label: 'Project Task' }); + expect(response.body.data.fields.done).toMatchObject({ type: 'boolean' }); + }); + + it('an item with only a DRAFT row is not served — a draft is not published', async () => { + const { engine, rows } = makeStubEngine(); + const metadata = new MetadataManager({}); + const protocol = makeProtocol(engine, metadata); + + // Authored, never published — the draft row exists and nothing else. + await protocol.saveMetaItem({ + type: 'object', + name: 'proj_task', + item: RUNTIME_BODY, + packageId: 'app.projects', + mode: 'draft', + }); + + // ANTI-VACUITY: the draft really is there, and no active row is. + expect(Array.from(rows.values()).filter((r) => r.state === 'draft')).toHaveLength(1); + expect(Array.from(rows.values()).filter((r) => r.state === 'active')).toHaveLength(0); + + const response = responseOf(await make({ protocol, metadata }) + .handleMetadata('/object/proj_task/published', ctx(), 'GET')); + + expect(response.status).toBe(404); + // And specifically: the DRAFT body was not served under another status. + expect(JSON.stringify(response.body ?? {})).not.toContain('Project Task'); + }); + + it('a CODE-published item still resolves through getPublished, byte-identically', async () => { + const { engine } = makeStubEngine(); + + // The code/package store — `publishedDefinition` is what + // `publishPackage` writes and what `getPublished` reads. + const published = { ...CODE_BODY, label: 'Code Widget (published)' }; + const metadata = new MetadataManager({}); + const protocol = makeProtocol(engine, metadata); + await metadata.register('object', 'code_widget', { + metadata: CODE_BODY, + publishedDefinition: published, + state: 'active', + } as any); + + const response = responseOf(await make({ protocol, metadata }) + .handleMetadata('/object/code_widget/published', ctx(), 'GET')); + + expect(response.status).toBe(200); + // BYTE-IDENTICAL to what `getPublished` itself answers — the overlay + // arm must not have decorated, folded or re-shaped this document. + const direct = await (metadata as any).getPublished('object', 'code_widget'); + expect(response.body.data).toEqual(direct); + expect(response.body.data).toEqual(published); + }); + + it('ANTI-VACUITY: the two fixtures resolve from DIFFERENT stores', async () => { + // Proves the suite can tell code-published from runtime-published — + // without this, all three cases above could be passing off one store. + const { engine, rows } = makeStubEngine(); + const metadata = new MetadataManager({}); + const protocol = makeProtocol(engine, metadata); + + await runtimePublish(protocol, 'proj_task', RUNTIME_BODY); + await metadata.register('object', 'code_widget', { + metadata: CODE_BODY, + publishedDefinition: CODE_BODY, + state: 'active', + } as any); + + // The runtime item exists ONLY as an overlay row… + expect(Array.from(rows.values()).some((r) => r.name === 'proj_task')).toBe(true); + expect(await (metadata as any).getPublished('object', 'proj_task')).toBeUndefined(); + + // …and the code item exists ONLY in the registry. + expect(Array.from(rows.values()).some((r) => r.name === 'code_widget')).toBe(false); + expect(await (metadata as any).getPublished('object', 'code_widget')).toBeDefined(); + + const dispatcher = make({ protocol, metadata }); + const runtimeRes = responseOf(await dispatcher.handleMetadata('/object/proj_task/published', ctx(), 'GET')); + const codeRes = responseOf(await dispatcher.handleMetadata('/object/code_widget/published', ctx(), 'GET')); + + expect(runtimeRes.status).toBe(200); + expect(codeRes.status).toBe(200); + expect(runtimeRes.body.data).toMatchObject({ label: 'Project Task' }); + expect(codeRes.body.data).toMatchObject({ label: 'Code Widget' }); + }); + + it('a name that exists in NEITHER store still 404s', async () => { + const { engine } = makeStubEngine(); + const metadata = new MetadataManager({}); + const protocol = makeProtocol(engine, metadata); + + const response = responseOf(await make({ protocol, metadata }) + .handleMetadata('/object/no_such_thing/published', ctx(), 'GET')); + + expect(response.status).toBe(404); + }); +}); diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index acb3fa3039..60a53800cd 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -234,6 +234,55 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin if (parts.length >= 3 && parts[parts.length - 1] === 'published' && (!method || method === 'GET')) { const type = parts[0]; const name = parts.slice(1, -1).join('/'); + + // [#8031] The AUTHORITATIVE published store is consulted first: the + // `state:'active'` `sys_metadata` overlay row. + // + // Two publish lifecycles write to two different places, and this route + // used to know only the older one: + // + // - `MetadataManager.publishPackage` snapshots a body into the + // row-local `publishedDefinition` key of its own in-memory + // registry — the ADR-0016-era package publish, which is what + // `getPublished` below reads. + // - `publishPackageDrafts` / `promoteDraft` flips the artifact's + // `sys_metadata` row `state:'draft' → 'active'`. ADR-0027 (E)(5) + // defines sealing a publish as exactly that flip, and + // `SysMetadataRepository` names `'active'` "the published, live + // overlay". ADR-0033 §2 — the ADR this route cites — routes EVERY + // authoring write into that same ADR-0027 draft, so promoting it + // is what "published" means for anything authored at runtime. + // + // The dispatcher's own `POST /packages/:id/publish-drafts` comment + // states that path has "no metadata service dependency", so the two + // shared no store at all: an item published at runtime was absent from + // the registry `getPublished` consults and this route answered 404 — + // a false statement about an item that IS published. + // + // `getMetaItemLayered` is the narrow primitive on purpose. Its overlay + // layer is a strict `state:'active'` lookup (org-scoped first, then + // env-wide, ADR-0048 package preference) that never reads a draft, and + // it reports that layer SEPARATELY from the code layer. So a null + // overlay is positively "no runtime-published row" and falls through to + // the untouched `getPublished` path below — which is what keeps a + // code-published item resolving to the same bytes it always did. The + // broader `getMetaItem` would not do: it folds the code layer into its + // own answer, so this route could no longer tell the two stores apart. + const protocol = await deps.resolveService(_context, 'protocol'); + if (protocol && typeof (protocol as any).getMetaItemLayered === 'function') { + try { + const organizationId = await deps.resolveActiveOrganizationId(_context); + const layered = await (protocol as any).getMetaItemLayered({ + type, + name, + ...(organizationId ? { organizationId } : {}), + }); + if (layered?.overlay !== undefined && layered?.overlay !== null) { + return { handled: true, response: deps.success(layered.overlay) }; + } + } catch { /* fall through to the code/package snapshot below */ } + } + const metadataService = await deps.getService(_context, CoreServiceName.enum.metadata); if (metadataService && typeof (metadataService as any).getPublished === 'function') { const data = await (metadataService as any).getPublished(type, name); From 9993cb37cd8c9536da129441cd1d000d94a763e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 00:51:38 +0000 Subject: [PATCH 2/2] test(runtime): pin the #8031 engine double to ObjectQL's write-verb dispatch contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:engine-double-contract` flagged this file's fake engine on BOTH write verbs: its `update()` and `delete()` accepted calls the real `ObjectQL` engine would refuse, and a double looser than the producer is how #4434 shipped a dead route with its suite green. Both verbs now open with the producer's own predicate — `assertEngineUpdateDispatch(data, options)` / `assertEngineDeleteDispatch(options)` — imported from `@objectstack/metadata-core`, where they have lived since #5619, rather than from `@objectstack/objectql` (which re-exports them but depends on this side of the graph, so the import would close a cycle turbo rejects). No baseline entry: the pin applies cleanly here. No production code changed — the five cases still pass, and reverting the `/published` fix still turns the two runtime-published cases red, so the suite remains load-bearing under the stricter double. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011Q72AE6sKzpP8Z4o1RM7xy --- .../src/domains/meta-published-runtime-publish.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/runtime/src/domains/meta-published-runtime-publish.test.ts b/packages/runtime/src/domains/meta-published-runtime-publish.test.ts index af7639c855..d2330c5681 100644 --- a/packages/runtime/src/domains/meta-published-runtime-publish.test.ts +++ b/packages/runtime/src/domains/meta-published-runtime-publish.test.ts @@ -27,6 +27,13 @@ */ import { describe, it, expect } from 'vitest'; +// The producer's OWN write-verb dispatch decisions, so the fake engine below +// cannot accept a call ObjectQL itself would refuse — a double looser than the +// real engine is how #4434 shipped a dead route with its suite green. Imported +// from `@objectstack/metadata-core` rather than `@objectstack/objectql` +// (which re-exports it): objectql depends on this side of the graph, so that +// import would close a cycle turbo rejects outright. +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import { MetadataManager } from '@objectstack/metadata'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { HttpDispatcher } from '../http-dispatcher.js'; @@ -101,6 +108,7 @@ function makeStubEngine() { return { id: row.id }; }, async update(_t: string, data: Record, opts: { where: Record }) { + assertEngineUpdateDispatch(data, opts); const found = findRow(opts.where); if (!found) return { id: null }; const merged = { ...found.row, ...(data as any) }; @@ -109,6 +117,7 @@ function makeStubEngine() { return { id: found.row.id }; }, async delete(_t: string, opts: { where: Record }) { + assertEngineDeleteDispatch(opts); const found = findRow(opts.where); if (!found) return { deleted: 0 }; rows.delete(found.key);