From a13568b487837bb65ac1efd448bc789c2ca8a81f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 09:58:39 +0000 Subject: [PATCH 1/2] fix(rest,runtime): ?package=all names no package on every /meta door of both hosts The layered read, the list, the book tree and the diagnostics sweep on RestServer, and the dispatcher's book tree, layered read, item read and save, and list, forwarded ?package=all to the store as a literal package id. The layered read answered 404, the list [], the book tree an empty implicit book, and the dispatcher's PUT bound the row to a package called all. Each now reads ?package= through metaItemPackageBinding, which @objectstack/rest exports from its entry for the runtime to take. Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-Authored-By: Claude --- .../22188-rest-meta-package-all-fold.md | 14 + .../22188-runtime-meta-package-all-fold.md | 12 + packages/rest/src/index.ts | 10 + packages/rest/src/rest-server.ts | 28 +- .../src/domains/meta-package-all-fold.test.ts | 322 ++++++++++++++++++ packages/runtime/src/domains/meta.ts | 23 +- 6 files changed, 398 insertions(+), 11 deletions(-) create mode 100644 .changeset/22188-rest-meta-package-all-fold.md create mode 100644 .changeset/22188-runtime-meta-package-all-fold.md create mode 100644 packages/runtime/src/domains/meta-package-all-fold.test.ts diff --git a/.changeset/22188-rest-meta-package-all-fold.md b/.changeset/22188-rest-meta-package-all-fold.md new file mode 100644 index 00000000000..87bc23a20d6 --- /dev/null +++ b/.changeset/22188-rest-meta-package-all-fold.md @@ -0,0 +1,14 @@ +--- +"@objectstack/rest": minor +--- + +`metaItemPackageBinding` is exported, and every `/meta` door that reads `?package=` reads it through that function, so `?package=all` names no package on the layered read, the list, the book tree and the diagnostics sweep + +Clause-②: yes (widening) + +- `metaItemPackageBinding(raw)` answers the package a request's `?package=` names, or `undefined` for `all` (the metadata list's "show everything" scope), the empty value or a non-string. The item read, the save and the publish already read `?package=` through it. The runtime dispatcher's `/meta` domain now reads it through the same function. +- `GET /meta/:type/:name/layers?package=all` answered `404` for an item stored in a package, and now serves the layers that the read without `?package=` serves. The Studio editor sends this read when it is opened from the list's "show everything" scope. +- `GET /meta/:type?package=all` answered `[]`, and now lists what `GET /meta/:type` lists: the items of every package and the env-local ones. +- `GET /meta/book/:name/tree?package=all` served the empty implicit book of a package called `all`, and now resolves the declared book as the read without `?package=` does. +- `GET /meta/diagnostics?package=all` swept nothing, and now sweeps what the plain sweep sweeps. +- Unchanged: a real package id still scopes each of these reads. The item read's cache bypass still reads the raw parameter, so `?package=all` keeps the uncached read and its served `version`. diff --git a/.changeset/22188-runtime-meta-package-all-fold.md b/.changeset/22188-runtime-meta-package-all-fold.md new file mode 100644 index 00000000000..e9b58aa2bf4 --- /dev/null +++ b/.changeset/22188-runtime-meta-package-all-fold.md @@ -0,0 +1,12 @@ +--- +"@objectstack/runtime": patch +--- + +The runtime dispatcher's `/meta` domain reads `?package=all` as naming no package, as `RestServer` does, so a `PUT ?package=all` through the `@objectstack/hono` catch-all no longer binds the row to a package called `all` + +Clause-②: no + +- The dispatcher's `PUT /meta/:type/:name?package=all` stored the row with `package_id: 'all'`. It now stores it env-local (`package_id` null), as `RestServer`'s `PUT` does. +- Its layered read (`/meta/:type/:name/layers`), list (`/meta/:type`), book tree (`/meta/book/:name/tree`) and item read forwarded `all` to the store as a package id. The layered read answered `404` and the list `[]` for an item stored in a package. Each now answers `?package=all` as it answers the same request without `?package=`. +- Every branch reads `?package=` through `metaItemPackageBinding` from `@objectstack/rest`, the function `RestServer`'s doors read it through. +- Unchanged: a real package id still scopes each read and still binds a save. diff --git a/packages/rest/src/index.ts b/packages/rest/src/index.ts index e87f506a585..9fd9e7980aa 100644 --- a/packages/rest/src/index.ts +++ b/packages/rest/src/index.ts @@ -183,3 +183,13 @@ export type { MetaSaveRequestMembers, MetaSaveRequestOptions, } from './meta-save-request.js'; + +// [#22188] …and the one reading of `?package=` on every `/meta` door: the +// package a request names, or none for `all` (the list's "show everything" +// scope), the empty value or a non-string. `RestServer`'s doors read it, and so +// does the runtime dispatcher's `/meta` domain on its own copies of them — the +// layered read, the list, the book tree and the item read and save — which +// forwarded `all` to the store as a package id until this landed. It stays in +// `rest-server.ts`, beside the doors that read it (its docblock is the +// authority); this entry already loads that module. +export { metaItemPackageBinding } from './rest-server.js'; diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 530b674d57e..4f3ef596879 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -1736,8 +1736,20 @@ function mayReadPendingDrafts(caller: unknown): boolean { * save then judged, and a client that pinned `If-None-Match: *` on that `null` * was refused. ⛔ Never a third inline copy at a door: the save and publish * doors each carried one, and the read door had none. + * + * [#22188] …and on every other `/meta` door that reads `?package=`: the + * layered read, the list, the book tree and the diagnostics sweep, which each + * forwarded `all` to the store as a package id no item is bound to (the + * layered read answered `404`, the list `[]`). Without `?package=`, the list + * already spans every package and the env-local overlay, so "no package" is + * the list's "show everything". Exported from the package entry, and the + * runtime dispatcher's `/meta` domain reads `?package=` through it on its own + * copies of these doors, so the two transports cannot read `all` differently. + * The item read's cache bypass (`packageScoped`) is not a second reading: it + * asks whether the raw parameter is present to pick the uncached arm, and that + * arm asks this function. */ -function metaItemPackageBinding(raw: unknown): string | undefined { +export function metaItemPackageBinding(raw: unknown): string | undefined { return typeof raw === 'string' && raw !== '' && raw !== 'all' ? raw : undefined; } @@ -3920,7 +3932,8 @@ export class RestServer { // `getMetaItemLayered({ packageId: ['a','b'] })`. Gated in the helper, // not in its two callers, so both entry points answer identically. if (refuseRepeatedQueryParams(req, res, ['package'])) return; - const layeredPackageId = req.query?.package || undefined; + // [#22188] Read through {@link metaItemPackageBinding}: `all` names no package. + const layeredPackageId = metaItemPackageBinding(req.query?.package); // [#9454] State the ORG scope, exactly as the `/published` overlay read // already does. Without it the layered view resolved the env-wide row // only, so an author who had just saved an org overlay opened Studio to @@ -5804,7 +5817,9 @@ export class RestServer { const result = await (p as any).getMetaDiagnostics({ type: diagnosticsType, severity, - packageId: (req.query?.package as string | undefined) || undefined, + // [#22188] The list's reading ({@link metaItemPackageBinding}): + // each swept type is a list read, so `all` names no package. + packageId: metaItemPackageBinding(req.query?.package), // SPREAD, never `organizationId: x ?? null` — the // implementation declares `organizationId?: string` // (optional plain string, not nullable), and a @@ -6045,7 +6060,9 @@ export class RestServer { // `query-multiplicity.ts` for why picking one of two // conflicting intents is worse than a 400. if (refuseRepeatedQueryParams(req, res, ['package', 'preview', 'object', 'include', 'id'])) return; - const packageId = req.query?.package || undefined; + // [#22188] Read through {@link metaItemPackageBinding}: + // `all`, this list's "show everything" scope, names no package. + const packageId = metaItemPackageBinding(req.query?.package); const environmentId = isScoped ? req.params?.environmentId : undefined; const p = await this.resolveProtocol(environmentId, req); // [#9488] …and BEFORE any listing work: a `:type` that @@ -6379,7 +6396,8 @@ export class RestServer { const prot = await this.resolveProtocol(environmentId, req); // [#6877] One package scopes the book lookup. if (refuseRepeatedQueryParams(req, res, ['package'])) return; - const packageId = req.query?.package || undefined; + // [#22188] Read through {@link metaItemPackageBinding}: `all` names no package. + const packageId = metaItemPackageBinding(req.query?.package); // [#20408] The route's whole answer is // `createMetaBookTreeAnswer` in `./meta-item-read-gate.ts` // — the book and doc reads, THE `DocsAudience` (#19790: one diff --git a/packages/runtime/src/domains/meta-package-all-fold.test.ts b/packages/runtime/src/domains/meta-package-all-fold.test.ts new file mode 100644 index 00000000000..c44c02257a1 --- /dev/null +++ b/packages/runtime/src/domains/meta-package-all-fold.test.ts @@ -0,0 +1,322 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22188] `?package=all` names no package on every `/meta` door of both + * hosts — `RestServer`'s routes and the runtime dispatcher's `/meta` domain, + * the only answer on a host that mounts just `@objectstack/hono`'s + * `${prefix}/*` catch-all. + * + * ## What was wrong + * + * `all` is the metadata list's "show everything" scope, and the empty value + * states nothing. #22128 gave the item read, the save and the publish one + * reading of `?package=` — `metaItemPackageBinding` (`@objectstack/rest`) — + * under which both name no package. The other doors forwarded the raw value, + * so `all` reached the store as a package id no item is bound to: + * + * - `RestServer`: the layered read answered `404`, the list `[]`, the book + * tree the implicit book of a package called `all` (no declared book, no + * page), and the diagnostics sweep scanned nothing; + * - the dispatcher: the same layered read, list and book tree, the item read, + * and its `PUT`, which WROTE the row bound to the package `all`. + * + * Every door now reads `?package=` through that one function, so a request + * naming `all` is answered as the same request naming nothing. + * + * ## The rig + * + * Each door gets its own REAL store — better-sqlite3 `:memory:`, the real + * `sys_metadata*` objects, a real `ObjectStackProtocolImplementation` — seeded + * through the protocol (not through either door), so both doors read the same + * rows: a view stored in package `com.probe.pkg`, a view in a second package, + * an env-local view, and a book and a page stored in `com.probe.pkg`. + * + * - `RestServer`: its registered handlers, called with the request the + * `plugin-hono-server` adapter builds, the auth boundary (`resolveExecCtx`) + * stubbed — as `meta-save-preconditions-parity.test.ts` drives it; + * - the catch-all: {@link catchAll} repeats the catch-all's four statements + * over a real Fetch `Request` into the real `HttpDispatcher.dispatch()` + * (that file's header says why they are repeated rather than imported). + * + * Each row compares a door's answer to `?package=all` with the SAME door's + * answer to the request without `?package=`, and keeps one control beside it: + * a real package id still scopes. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadata, SysMetadataHistoryObject, SysMetadataAuditObject } from '@objectstack/platform-objects/metadata'; +import { RestServer } from '@objectstack/rest'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +const PREFIX = '/api/v1'; +const META = `${PREFIX}/meta`; +/** `registry.registerObject` requires a package id (see the sibling real-stack tests). */ +const TEST_PACKAGE_ID = 'objectstack-test'; +/** The package the probed items are stored in. */ +const PROBE_PKG = 'com.probe.pkg'; +/** A second installed package: a real id that does not own the probed items. */ +const OTHER_PKG = 'com.other.pkg'; +/** An author: `manage_metadata` saves, on both doors. */ +const AUTHOR = { userId: 'u_author', isSystem: false, systemPermissions: ['manage_metadata'] }; + +const TASK = { + name: 'task', + label: 'Task', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + name: { name: 'name', type: 'text' as const, label: 'Name' }, + }, +}; + +const view = (name: string, label: string) => ({ + name, + label, + object: 'task', + viewKind: 'list', + columns: [{ field: 'name', label: 'Name' }], +}); + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)); + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +/** One real store, seeded through the protocol: the rows every door below reads. */ +async function bootStore() { + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }), true); + await engine.init(); + engine.registry.registerObject(TASK as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadata as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataHistoryObject as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataAuditObject as any, TEST_PACKAGE_ID); + await engine.syncSchemas(); + const protocol = new ObjectStackProtocolImplementation(engine as any); + const seed = (type: string, name: string, item: unknown, packageId?: string) => + protocol.saveMetaItem({ type, name, item, ...(packageId ? { packageId } : {}) } as any); + await seed('view', 'case_grid', view('case_grid', 'live'), PROBE_PKG); + await seed('view', 'other_grid', view('other_grid', 'other'), OTHER_PKG); + await seed('view', 'local_grid', view('local_grid', 'local')); + await seed('book', 'probe_book', { + name: 'probe_book', label: 'Probe Book', groups: [{ key: 'pages', label: 'Pages', include: 'probe_*' }], + }, PROBE_PKG); + await seed('doc', 'probe_page', { name: 'probe_page', label: 'Probe page', content: 'Hello.' }, PROBE_PKG); + /** The stored package binding of each row of a view — the store, not the door's word for it. */ + const boundTo = async (name: string): Promise> => { + const rows = await engine.find('sys_metadata', { where: { type: 'view', name } }); + return (rows ?? []).map((r: any) => r.package_id ?? null); + }; + return { protocol, boundTo }; +} + +// ── The two doors ───────────────────────────────────────────────────────────── + +interface Answer { status: number; code?: string; data?: any } +interface Door { + read(path: string, query?: Record): Promise; + put(path: string, item: unknown, query?: Record): Promise; + boundTo(name: string): Promise>; +} + +/** The refusal's `code` in either door's envelope: nested under `error`, or beside a string `error`. */ +const codeOf = (body: any): string | undefined => + body?.error && typeof body.error === 'object' ? body.error.code : body?.code; + +function makeRes() { + const res: any = { statusCode: 200, body: undefined, headers: {} as Record }; + res.status = (code: number) => { res.statusCode = code; return res; }; + res.json = (body: any) => { res.body = body; return res; }; + res.send = () => res; + res.end = () => res; + res.header = (k: string, v: string) => { res.headers[k] = v; return res; }; + res.setHeader = () => {}; + res.write = () => true; + return res; +} + +/** `RestServer`'s route for a path below `/meta`, and the params the adapter would hand it. */ +function restRouteOf(path: string): { route: string; params: Record } { + const s = path.replace(/^\/meta\//, '').split('/'); + if (s.length === 1 && s[0] === 'diagnostics') return { route: `${META}/diagnostics`, params: {} }; + if (s.length === 3 && s[0] === 'book' && s[2] === 'tree') return { route: `${META}/book/:name/tree`, params: { name: s[1] } }; + if (s.length === 3 && s[2] === 'layers') return { route: `${META}/:type/:name/layers`, params: { type: s[0], name: s[1] } }; + if (s.length === 2) return { route: `${META}/:type/:name`, params: { type: s[0], name: s[1] } }; + if (s.length === 1) return { route: `${META}/:type`, params: { type: s[0] } }; + throw new Error(`no RestServer route for ${path}`); +} + +async function bootRestServer(): Promise { + const { protocol, boundTo } = await bootStore(); + const noop = () => {}; + const server = { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; + const rest: any = new RestServer(server as any, protocol as any, { api: { requireAuth: false } } as any); + rest.resolveExecCtx = async () => clone(AUTHOR); + rest.registerRoutes(); + const drive = async (method: string, path: string, query: Record, body?: unknown): Promise => { + const { route, params } = restRouteOf(path); + const entry = rest.getRoutes().find((r: any) => r.method === method && r.path === route); + if (!entry) throw new Error(`${method} ${route} is not registered`); + const res = makeRes(); + await entry.handler({ method, path: `${PREFIX}${path}`, params, query: { ...query }, headers: {}, body }, res); + return { status: res.statusCode, code: codeOf(res.body), data: res.statusCode === 200 ? res.body : undefined }; + }; + return { + boundTo, + read: (path, query = {}) => drive('GET', path, query), + put: (path, item, query = {}) => drive('PUT', path, query, item), + }; +} + +/** + * What `createHonoApp`'s `${prefix}/*` catch-all hands `dispatch()`, statement + * for statement: the path below the prefix, a JSON body for `POST` / `PUT` / + * `PATCH`, the query flattened from the URL, and the raw Fetch `Request` as + * `context.request`. + */ +async function catchAll(dispatcher: HttpDispatcher, raw: Request) { + const subPath = new URL(raw.url).pathname.substring(PREFIX.length); + const method = raw.method; + let body: any = undefined; + if (method === 'POST' || method === 'PUT' || method === 'PATCH') { + body = await raw.clone().json().catch(() => ({})); + } + const queryParams: Record = {}; + new URL(raw.url).searchParams.forEach((val, key) => { queryParams[key] = val; }); + return dispatcher.dispatch(method, subPath, body, queryParams, { request: raw }, PREFIX); +} + +async function bootCatchAll(): Promise { + const { protocol, boundTo } = await bootStore(); + const get = (n: string) => (n === 'protocol' ? protocol : null); + const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) }; + const dispatcher = new HttpDispatcher(kernel); + // The seam `dispatch()` resolves identity through (the sibling + // dispatch-level pins supply the principal the same way). + (dispatcher as any).timedResolveExecutionContext = async () => clone(AUTHOR); + const drive = async (method: string, path: string, query: Record, item?: unknown): Promise => { + const url = new URL(`http://localhost${PREFIX}${path}`); + for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v); + const raw = new Request(url, { + method, + headers: { 'content-type': 'application/json' }, + ...(item !== undefined ? { body: JSON.stringify(item) } : {}), + }); + const res = await catchAll(dispatcher, raw); + const body = res.response?.body; + const status = res.response?.status ?? 0; + return { status, code: codeOf(body), data: status === 200 ? body?.data : undefined }; + }; + return { + boundTo, + read: (path, query = {}) => drive('GET', path, query), + put: (path, item, query = {}) => drive('PUT', path, query, item), + }; +} + +const DOORS: ReadonlyArray Promise]> = [ + ['RestServer', bootRestServer], + ['the @objectstack/hono catch-all', bootCatchAll], +]; + +/** The names a list answer serves, in either door's body (a bare array or `{ items }`). */ +const namesOf = (data: any): string[] => + (Array.isArray(data) ? data : Array.isArray(data?.items) ? data.items : []).map((i: any) => i?.name).sort(); +const served = (a: Answer) => { + expect(a.status, JSON.stringify(a).slice(0, 400)).toBe(200); + return a.data; +}; + +// ── Each door, on each host ─────────────────────────────────────────────────── + +for (const [doorName, boot] of DOORS) { + describe(`[#22188] ${doorName}: ?package=all names no package`, () => { + it('the layered read: ?package=all serves the layers the plain read serves; a real package id still scopes', async () => { + const door = await boot(); + const plain = served(await door.read('/meta/view/case_grid/layers')); + expect(plain.overlay?.label).toBe('live'); + expect(served(await door.read('/meta/view/case_grid/layers', { package: 'all' }))).toEqual(plain); + // Control: the owning package resolves the same layers; another + // package's id finds no such item there — the answer `all` got. + expect(served(await door.read('/meta/view/case_grid/layers', { package: PROBE_PKG }))).toEqual(plain); + const other = await door.read('/meta/view/case_grid/layers', { package: OTHER_PKG }); + expect({ status: other.status, code: other.code }).toEqual({ status: 404, code: 'RESOURCE_NOT_FOUND' }); + }, 60_000); + + it('the list: ?package=all lists what the plain list lists — every package\'s items and the env-local ones', async () => { + const door = await boot(); + const plain = namesOf(served(await door.read('/meta/view'))); + // "No package" is "show everything" here: the plain list already + // spans both packages and the env-local overlay. + expect(plain).toEqual(expect.arrayContaining(['case_grid', 'other_grid', 'local_grid'])); + expect(namesOf(served(await door.read('/meta/view', { package: 'all' })))).toEqual(plain); + // Control: a real package id still scopes the list to that package's items. + expect(namesOf(served(await door.read('/meta/view', { package: PROBE_PKG })))).toEqual(['case_grid']); + expect(namesOf(served(await door.read('/meta/view', { package: OTHER_PKG })))).toEqual(['other_grid']); + }, 60_000); + + it('the book tree: ?package=all resolves the declared book the plain read resolves; a real package id still scopes', async () => { + const door = await boot(); + const plain = served(await door.read('/meta/book/probe_book/tree')); + expect(plain.label).toBe('Probe Book'); + expect(JSON.stringify(plain)).toContain('probe_page'); + expect(served(await door.read('/meta/book/probe_book/tree', { package: 'all' }))).toEqual(plain); + // Control: the owning package resolves the same book; another + // package's scope holds no such book, so the name falls to that + // scope's implicit book (ADR-0046 §6.4) — no declared label, no + // page — the answer `all` got. + expect(served(await door.read('/meta/book/probe_book/tree', { package: PROBE_PKG }))).toEqual(plain); + const other = served(await door.read('/meta/book/probe_book/tree', { package: OTHER_PKG })); + expect(other.label).toBe('probe_book'); + expect(JSON.stringify(other)).not.toContain('probe_page'); + }, 60_000); + + it('the item read: ?package=all serves the item the plain read serves', async () => { + const door = await boot(); + const plain = served(await door.read('/meta/view/case_grid')); + expect(plain.item?.label).toBe('live'); + expect(served(await door.read('/meta/view/case_grid', { package: 'all' })).item).toEqual(plain.item); + }, 60_000); + + it('the save: PUT ?package=all writes the env-local row, as a save naming no package does; a real package id still binds', async () => { + const door = await boot(); + served(await door.put('/meta/view/fresh_grid', view('fresh_grid', 'unscoped'), { package: 'all' })); + expect(await door.boundTo('fresh_grid')).toEqual([null]); + // Control: the owning package binds the row. + served(await door.put('/meta/view/bound_grid', view('bound_grid', 'bound'), { package: PROBE_PKG })); + expect(await door.boundTo('bound_grid')).toEqual([PROBE_PKG]); + }, 60_000); + }); +} + +// ── RestServer's diagnostics sweep ──────────────────────────────────────────── + +/** + * `GET /meta/diagnostics` hands its `?package=` to the list read of every type + * it sweeps, so it reads it the way the list does. The dispatcher serves no + * such route. + */ +describe('[#22188] RestServer: GET /meta/diagnostics?package=all sweeps what the plain sweep sweeps', () => { + it('the same items scanned and the same packages seen; a real package id still scopes', async () => { + const door = await bootRestServer(); + const plain = served(await door.read('/meta/diagnostics', { type: 'view' })); + expect(plain.scannedItems).toBeGreaterThanOrEqual(3); + expect(plain.stats?.view?.packages).toEqual(expect.arrayContaining([PROBE_PKG, OTHER_PKG])); + const all = served(await door.read('/meta/diagnostics', { type: 'view', package: 'all' })); + expect({ scannedItems: all.scannedItems, stats: all.stats }).toEqual({ scannedItems: plain.scannedItems, stats: plain.stats }); + // Control: a real package id still scopes the sweep. + const scoped = served(await door.read('/meta/diagnostics', { type: 'view', package: PROBE_PKG })); + expect(scoped.scannedItems).toBeLessThan(plain.scannedItems); + }, 60_000); +}); diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index e90328952e7..b21893a63ef 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -61,6 +61,8 @@ import type { ObjectStackProtocolImplementation } from '@objectstack/metadata-pr // served under. // [#22141] …and the save door's precondition and lifecycle // (`metaSaveRequestOptions`): `If-Match` / `If-None-Match: *` and `?mode=draft`. +// [#22188] …and the one reading of `?package=` (`metaItemPackageBinding`), +// under which `all` names no package, on every branch here that reads it. // Imported, never restated — AGENTS.md 〈Route & surface ownership〉 rule 1. import { createMetaBookTreeAnswer, @@ -71,6 +73,7 @@ import { isPublicAudienceRead, metaCallerOrganizationId, metaItemLayersDeprecationHeaders, + metaItemPackageBinding, metaReadOrganizationId, metaRequestLocale, metaSaveRequestOptions, @@ -1031,7 +1034,8 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin if (!protocol || typeof protocol.getMetaItems !== 'function') { return { handled: true, response: deps.error('Not found', 404) }; } - const packageId = typeof query?.package === 'string' && query.package.length > 0 ? query.package : undefined; + // [#22188] `RestServer`'s reading: `all` names no package. + const packageId = metaItemPackageBinding(query?.package); try { const answer = await createMetaBookTreeAnswer( { @@ -1085,7 +1089,8 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin response: deps.error('Layered metadata view not supported by protocol implementation', 501), }; } - const packageId = query?.package || undefined; + // [#22188] `RestServer`'s reading: `all` names no package. + const packageId = metaItemPackageBinding(query?.package); return answerMetaLayered( deps, _context, protocol as MetaLayeredProtocol, { type, name, packageId, maskPosture }, saveVerdict, @@ -1251,8 +1256,12 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin if (parts.length === 2) { const type = parts[0]; const name = decodeMetaNameSegment(parts[1]); - // Extract optional package filter from query string - const packageId = query?.package || undefined; + // Extract optional package filter from query string. + // [#22188] `RestServer`'s reading, for the read and the save alike: + // `all` names no package. It forwarded the literal, so a + // `PUT ?package=all` stored the row bound to a package called `all` + // where `RestServer` stores it env-local. + const packageId = metaItemPackageBinding(query?.package); // PUT /metadata/:type/:name (Save) // @@ -1915,8 +1924,10 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // GET /metadata/:type (List items of type) OR /metadata/:objectName (Legacy) if (parts.length === 1) { const typeOrName = parts[0]; - // Extract optional package filter from query string - const packageId = query?.package || undefined; + // Extract optional package filter from query string. + // [#22188] `RestServer`'s reading: `all`, the list's "show everything" + // scope, names no package. + const packageId = metaItemPackageBinding(query?.package); // Try protocol service first for any type const protocol = await resolveProtocol(deps, _context); From db727d0d1b23908aef12d2d622b223c244b8646a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 10:17:03 +0000 Subject: [PATCH 2/2] test(runtime): pin the draft read's ?package=all on both hosts The Studio editor reads a pending draft back with its raw router ?package=, so under the list's "show everything" scope it sends ?state=draft&package=all. Both hosts now serve the draft ?state=draft serves. Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU Co-Authored-By: Claude --- .../runtime/src/domains/meta-package-all-fold.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/runtime/src/domains/meta-package-all-fold.test.ts b/packages/runtime/src/domains/meta-package-all-fold.test.ts index c44c02257a1..2ebea79d6b1 100644 --- a/packages/runtime/src/domains/meta-package-all-fold.test.ts +++ b/packages/runtime/src/domains/meta-package-all-fold.test.ts @@ -289,6 +289,17 @@ for (const [doorName, boot] of DOORS) { expect(served(await door.read('/meta/view/case_grid', { package: 'all' })).item).toEqual(plain.item); }, 60_000); + it('the draft read: ?state=draft&package=all serves the draft ?state=draft serves', async () => { + const door = await boot(); + // A draft saved naming no package inherits the package of the + // item's active row (#11087): the draft the Studio editor reads + // back with its raw router `?package=`. + served(await door.put('/meta/view/case_grid', view('case_grid', 'staged'), { mode: 'draft' })); + const plain = served(await door.read('/meta/view/case_grid', { state: 'draft' })); + expect(plain.item?.label).toBe('staged'); + expect(served(await door.read('/meta/view/case_grid', { state: 'draft', package: 'all' })).item).toEqual(plain.item); + }, 60_000); + it('the save: PUT ?package=all writes the env-local row, as a save naming no package does; a real package id still binds', async () => { const door = await boot(); served(await door.put('/meta/view/fresh_grid', view('fresh_grid', 'unscoped'), { package: 'all' }));