Skip to content

Commit 75d46f1

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-23462-dispatcher-patch-version
2 parents ae61e9d + 1baf976 commit 75d46f1

20 files changed

Lines changed: 1532 additions & 11 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/hono': minor
3+
'@objectstack/runtime': minor
4+
---
5+
6+
fix(hono,runtime)!: `createHonoApp` hands the dispatcher a repeated query name as an array, so the dispatcher's arity rules refuse it instead of answering its last value; `/packages` refuses a repeated `keepData` or `overwrite`
7+
8+
`@objectstack/hono`'s `createHonoApp` catch-all built the dispatcher's query by copying `URLSearchParams` into an object, so a name sent twice reached `HttpDispatcher.dispatch()` as its LAST value and the first was dropped without a word. Every arity rule the dispatcher declares was therefore unreachable on a `createHonoApp` host: `GET /meta/app/crm?package=a&package=b` was answered `200` for package `b`, where the same request through `@objectstack/plugin-hono-server` is refused. The catch-all now reads the query the way `@objectstack/plugin-hono-server` does: a name sent once is the string it always was, a repeated name is the array of its values, and the object is built without dynamic property writes, so a `__proto__` name is an ordinary own key.
9+
10+
Two `/packages` write flags read a repeat as absent, and are now refused before the registry or the protocol is touched, as `enabled` and `version` already are. `DELETE /packages/:id?keepData=true&keepData=true` dropped the package's object tables, because the protocol keeps storage only for `keepData === true`, and was answered `200`. `POST /packages?overwrite=true&overwrite=true` answered `409` "already exists" for a package one `overwrite=true` replaces. Both doors were reached that way through `@objectstack/plugin-hono-server`, whose adapter already keeps repeats, and would have been through `createHonoApp` from this change.
11+
12+
Clause-②: yes (narrowing)
13+
14+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling or stored shape is added, removed, renamed or re-shaped, and no exported declaration is added, removed or narrowed (the adapter's query reader and the domain's flag guard are both module-private). No stored row or package source changes, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is a query accept set: on a `createHonoApp` host every dispatcher read point that refuses a repeated single-valued name now sees the repeat and refuses it, and on every host `DELETE /packages/:id` and `POST /packages` refuse a repeated `keepData` and `overwrite`. One malformed input widens: on a `createHonoApp` host, a query pair with an empty name is no longer refused, matching `@objectstack/plugin-hono-server`. The remedy is in the request a caller sends, never a rewrite of a package source or a stored row. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
15+
16+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
17+
18+
What changes for you: send each single-valued query name once. On a `createHonoApp` host, a request that repeats one is now refused `400` in the dispatcher's envelope, `{ success: false, error: { code, message } }`, with the code and sentence the same request gets through `@objectstack/plugin-hono-server` (`VALIDATION_ERROR`, "The "NAME" query parameter was supplied N times…"; `INVALID_FILTER` on the `/data` filter slot), where it used to be answered with the last value. On every host, `DELETE /packages/:id` refuses a repeated `keepData` and `POST /packages` a repeated `overwrite`, with that same `VALIDATION_ERROR` and sentence. A name sent once is answered as before; genuinely multi-valued names such as `select` and `expand` now reach the dispatcher as the array they are. One malformed input moves the other way, to match `@objectstack/plugin-hono-server`: a query pair with an EMPTY name (`?=x`) is no longer handed to the dispatcher, so a door with a closed parameter set reads it as absent where it used to refuse it as an unknown parameter `""`. The `@objectstack/client` SDK sends each single-valued name once, and the Console's package delete sends `keepData` once.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/objectql': minor
4+
'@objectstack/runtime': minor
5+
---
6+
7+
A hook can now tell a child delete that a parent's cascade issues from a hand delete: `HookContext.referentialCascadeDelete`, the delete twin of `referentialFieldClear`
8+
9+
Clause-②: yes
10+
11+
When a record is deleted and a relation whose `deleteBehavior` resolves to `cascade` (a `master_detail` unless it declares `restrict`, or a `lookup` / `tree` that declares `cascade`) removes its children, the engine deletes each child as the calling user. The child's `beforeDelete` and `afterDelete` hooks therefore saw the same `ctx.user`, `ctx.session` and `ctx.input` as a user deleting that one child by hand. A guard that freezes settled records, such as a quote's lines once the quote is accepted, could refuse both deletes or neither, and refusing both made the parent, and every record whose cascade reaches it, undeletable.
12+
13+
- **What the key means.** `ctx.referentialCascadeDelete === true` exactly on a delete the engine's cascade issued: the child delete, in both phases, at any depth (a grandchild that the child's own cascade removes is marked too). It is absent, never `false`, on every other delete: the delete the caller asked for, a user's hand delete of the same child, and any write a hook issues through `ctx.api` during a cascade, deletes included. The `set_null` cleanup update keeps `referentialFieldClear` and never carries this key, and a cascade child delete never carries `referentialFieldClear`.
14+
- **The guard idiom.** Refuse a hand delete of a frozen line and yield to the cascade:
15+
16+
```ts
17+
if (ctx.referentialCascadeDelete !== true && ctx.previous?.locked === true) {
18+
throw new Error('This line belongs to a settled quote and cannot be deleted.');
19+
}
20+
```
21+
22+
- **Only the declared key is the contract.** There is no operation-private (`__`-prefixed) spelling of this fact on the execution context. The engine recognises the delete its cascade issued through an engine-private record keyed on that child's object and id, so nothing on `ctx.api`, `ctx.transaction` or any `__` key can stand in for it. Read `ctx.referentialCascadeDelete`.
23+
- **In a sandboxed hook body.** The key is carried across the QuickJS boundary by contract, so a lowered `*.hook.ts` body reads `ctx.referentialCascadeDelete` exactly as an in-process handler does.
24+
- **spec (minor):** `HookContextSchema` declares `referentialCascadeDelete` (boolean, optional). One optional engine-produced key on the non-strict runtime hook context; nothing previously valid changes meaning.
25+
- **objectql (minor):** `delete()`'s hook-context assembly sets the key on the delete the cascade walk issues. The walk still hands the child the caller's context unchanged, so no other hook-context member moves.
26+
- **runtime (minor):** `buildSandboxContext` and `installCtx` carry the key into the VM.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
fix(metadata-protocol)!: a metadata delete with `dropStorage` drops an object's table only with the object's last stored row, and is refused `409 RESOURCE_CONFLICT` while another row of the object remains (ADR-0048)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no authorable spelling, export or stored shape is removed, renamed or re-shaped, and no stored row is rewritten, converted or deleted, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is one runtime write door: a metadata delete that asks to drop an object's table, while another stored row of the object stays after it, is refused before anything is written; the remedy is in the request a caller sends (drop the opt-in, or delete the other rows first), never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and no exported declaration is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
A `sys_metadata` row is keyed by its package (ADR-0048), so one object can hold one row per package, and a package-less row beside them. Every such row serves the object over the one table its name maps to.
14+
15+
**FROM.** `DELETE /api/v1/meta/object/my_object?package=com.acme.a&dropStorage=true` (`deleteMetaItem({ type: 'object', name, packageId, dropStorage: true })` in process), of an object a second package `com.acme.b` also holds, removed `com.acme.a`'s row, answered `200` with a receipt saying `com.acme.b`'s row remains, and dropped the object's table. The object stayed defined and served by `com.acme.b`'s row while its records were gone: its next data read answered `500`. The same held for a package-less row left beside the deleted package's row, and for an uninstall (`deletePackage`, which deletes each of the package's rows with `dropStorage` unless `keepData` is set).
16+
17+
**TO.** Such a delete is refused `409 RESOURCE_CONFLICT` before anything is written: both rows, the table and its records stay. The message names the rows that remain and the way out. An uninstall reports the refusal for that object in its `failed[]` list, as it reports any per-item refusal, and leaves the object's row and table in place.
18+
19+
**Unchanged.** The delete of an object's last stored row with `dropStorage` still drops its table. A delete without `dropStorage` still removes the named package's row and keeps the table, and its receipt names the rows that remain. Only `active` rows count: a draft is never materialised, so a remaining draft does not keep the table.
20+
21+
**Remedy.** To remove one package's row of an object another row still holds, send the delete without `dropStorage`. To drop the table, delete the object's other rows first, then send the last row's delete with `dropStorage`.

‎content/docs/references/data/hook.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
9999
| **session** | `{ userId?: string; actor?: string; organizationId?: string; accessToken?: string; … }` | optional | Current session context |
100100
| **provenance** | `{ flowRunId?: string; attributedUserId?: string; performedByClientId?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) |
101101
| **referentialFieldClear** | `boolean` | optional | Engine-produced marker: true exactly when this write is the engine's own referential cleanup (the set_null cascade UPDATE clearing — or, for multiple: true, removing the deleted member from — a lookup that references a record being deleted). Absent on every other write, including a user hand-clearing the same lookup; read as `ctx.referentialFieldClear === true`. The declared projection of the operation-private `__referentialFieldClear`, carried across the sandbox boundary by contract; server-derived and never client-supplied. |
102+
| **referentialCascadeDelete** | `boolean` | optional | Engine-produced marker: true exactly when this delete is one the engine's cascade issued (the child delete removing a row that references a record being deleted through a relation whose deleteBehavior resolves to cascade, at any depth), in both phases of that delete. Absent on every other delete, including a user hand-deleting the same child and a delete a hook issues through ctx.api; read as `ctx.referentialCascadeDelete === true`. The delete twin of referentialFieldClear, carried across the sandbox boundary by contract; server-derived, never client-supplied, and with no operation-private spelling to read instead. |
102103
| **transaction** | `any` | optional | Database transaction handle |
103104
| **ql** | `any` | ✅ | ObjectQL Engine Reference |
104105
| **api** | `any` | optional | Cross-object data access (IScopedContext — `object(name)` + `transaction(cb)`) |
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#23214] The `${prefix}/*` catch-all hands `dispatch()` a REPEATED query name
5+
* as an array, and a single-valued one as the plain string it always handed.
6+
*
7+
* ## The defect
8+
*
9+
* The catch-all copied `new URL(c.req.url).searchParams.forEach` into an
10+
* object, so `?package=a&package=b` reached `dispatch()` as `'b'`: the first
11+
* value was dropped without a word, and every arity rule the dispatcher
12+
* declares was unreachable on a `createHonoApp` host. It now reads the query as
13+
* `plugin-hono-server`'s `readQuery` does (`readDispatchQuery`).
14+
*
15+
* ## What is pinned here
16+
*
17+
* 1. THE SHAPE: a repeated name arrives as the array of every value, in order.
18+
* 2. THE CONTROL: every single-valued name arrives as the string the old line
19+
* produced — checked against a reference Hono app that runs that exact
20+
* line, so "unchanged" is a comparison, not a recollection.
21+
* 3. `__proto__` is an OWN key and no prototype moves. The old dynamic write
22+
* dropped the name silently; `Object.fromEntries` keeps it as data.
23+
*
24+
* The dispatcher is a recording fixture: this package's config aliases
25+
* `@objectstack/runtime` to a stub for the whole suite. That the REAL
26+
* dispatcher then refuses the repeat — `/meta`, `/packages`, `?keepData` — is
27+
* pinned through a real `createHonoApp` in
28+
* `packages/qa/http-conformance/src/hono-query-multiplicity.conformance.test.ts`.
29+
*/
30+
31+
import { describe, it, expect, vi, beforeEach } from 'vitest';
32+
import { Hono } from 'hono';
33+
34+
const seen: unknown[] = [];
35+
36+
const mockDispatcher = {
37+
getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', routes: {} }),
38+
handleAuth: vi.fn(async () => ({ handled: true, response: { status: 200, body: { ok: true } } })),
39+
dispatch: vi.fn(async (_method: string, _path: string, _body: unknown, query: unknown) => {
40+
seen.push(query);
41+
return { handled: true, response: { status: 200, body: { ok: true } } };
42+
}),
43+
};
44+
45+
vi.mock('@objectstack/runtime', () => ({
46+
HttpDispatcher: function HttpDispatcher() { return mockDispatcher; },
47+
}));
48+
49+
import { createHonoApp } from './index';
50+
51+
const PREFIX = '/api/v1';
52+
const app = createHonoApp({ kernel: { name: 'test-kernel' } as any, prefix: PREFIX, cors: false });
53+
54+
/** What `dispatch()` was handed as its query for one request through the catch-all. */
55+
async function dispatchedQuery(search: string): Promise<any> {
56+
seen.length = 0;
57+
const res = await app.request(`http://localhost${PREFIX}/meta/app/crm${search}`);
58+
expect(res.status).toBe(200);
59+
expect(seen).toHaveLength(1);
60+
return seen[0];
61+
}
62+
63+
/** The catch-all's query line as it was before #23214 — the reference for "unchanged". */
64+
async function referenceQuery(search: string): Promise<Record<string, any>> {
65+
let out: Record<string, any> = {};
66+
const ref = new Hono();
67+
ref.get('/*', (c) => {
68+
const queryParams: Record<string, any> = {};
69+
const url = new URL(c.req.url);
70+
url.searchParams.forEach((val, key) => { queryParams[key] = val; });
71+
out = queryParams;
72+
return c.text('ok');
73+
});
74+
await ref.request(`http://localhost/x${search}`);
75+
return out;
76+
}
77+
78+
beforeEach(() => { seen.length = 0; });
79+
80+
describe('[#23214] a repeated query name reaches dispatch() as an array', () => {
81+
it.each([
82+
['?package=a&package=b', { package: ['a', 'b'] }],
83+
['?package=a&package=b&package=c', { package: ['a', 'b', 'c'] }],
84+
['?keepData=true&keepData=true&layers=1', { keepData: ['true', 'true'], layers: '1' }],
85+
['?status=a&type=x&status=b', { status: ['a', 'b'], type: 'x' }],
86+
])('%s', async (search, expected) => {
87+
expect(await dispatchedQuery(search)).toEqual(expected);
88+
});
89+
});
90+
91+
describe('[#23214] a single-valued name arrives exactly as the old line produced it', () => {
92+
it.each([
93+
[''],
94+
['?package=crm'],
95+
['?package=crm&preview=draft&locale=fr'],
96+
['?q=hello+world'],
97+
['?q=hello%20world&x=%E4%B8%AD'],
98+
['?na%6De=v'],
99+
['?empty='],
100+
['?flag'],
101+
['?select=id,name'],
102+
])('%j', async (search) => {
103+
const got = await dispatchedQuery(search);
104+
expect(got).toEqual(await referenceQuery(search));
105+
for (const value of Object.values(got)) expect(typeof value).toBe('string');
106+
});
107+
});
108+
109+
describe('[#23214] `__proto__` is an own key, and no prototype moves', () => {
110+
it('keeps one `__proto__` as an own data property', async () => {
111+
const got = await dispatchedQuery('?__proto__=x&polluted=1');
112+
expect(Object.prototype.hasOwnProperty.call(got, '__proto__')).toBe(true);
113+
expect(Object.getOwnPropertyDescriptor(got, '__proto__')?.value).toBe('x');
114+
expect(Object.getPrototypeOf(got)).toBe(Object.prototype);
115+
expect(({} as any).polluted).toBeUndefined();
116+
expect(got.polluted).toBe('1');
117+
});
118+
119+
it('keeps a repeated `__proto__` as an own array', async () => {
120+
const got = await dispatchedQuery('?__proto__=x&__proto__=y');
121+
expect(Object.getOwnPropertyDescriptor(got, '__proto__')?.value).toEqual(['x', 'y']);
122+
expect(Object.getPrototypeOf(got)).toBe(Object.prototype);
123+
});
124+
});

‎packages/adapters/hono/src/index.ts‎

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,44 @@ async function readDispatchBody(c: Context): Promise<any> {
328328
}
329329
}
330330

331+
/**
332+
* The query the catch-all hands `dispatch()`: a REPEATED name as an array, a
333+
* single-valued one as a plain string (#23214).
334+
*
335+
* The twin is `readQuery` in `@objectstack/plugin-hono-server`
336+
* (`src/adapter.ts`), which every `plugin-hono-server` route reads its query
337+
* through. It is mirrored here, not imported: it is module-private there, and
338+
* that package's `exports` has only `.`. Keep the two in step.
339+
*
340+
* The catch-all used to copy `URLSearchParams.forEach` into an object, so a
341+
* repeated name arrived as its LAST value: `?package=a&package=b` reached a
342+
* dispatcher domain as `'b'`. Every arity rule the dispatcher declares
343+
* (`repeatedQueryParamMessage`, answered `400` through `plugin-hono-server`
344+
* and the node reference adapter) was unreachable on a `createHonoApp` host,
345+
* and the request was answered with one of the two values the caller sent.
346+
* The cli-lane ruling on #6878 decided the direction for every adapter: the
347+
* handler must be able to SEE the ambiguity in order to refuse it.
348+
*
349+
* ⚠️ The normalisation is NOT optional. `c.req.queries()` returns an array
350+
* for EVERY key, single-valued ones included (measured on hono 4.13:
351+
* `?a=1&a=2&b=3` gives `{ a: ['1', '2'], b: ['3'] }`), so a bare swap would
352+
* turn every single-value read point in the dispatcher into an array.
353+
*
354+
* Built with `Object.fromEntries`, never dynamic property writes: the keys
355+
* come straight off the wire, and `fromEntries` creates own data properties
356+
* only, so `?__proto__=x` is an own key named `__proto__` and no prototype is
357+
* touched. The old `queryParams[key] = val` write silently dropped that name.
358+
*/
359+
function readDispatchQuery(c: Context): Record<string, string | string[]> {
360+
const all: Record<string, string[]> = c.req.queries() ?? {};
361+
return Object.fromEntries(
362+
Object.entries(all).map(([key, values]) => [
363+
key,
364+
values.length > 1 ? values : values[0],
365+
]),
366+
) as Record<string, string | string[]>;
367+
}
368+
331369
/**
332370
* Creates a full-featured Hono app with all ObjectStack route dispatchers.
333371
*
@@ -784,9 +822,8 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
784822
body = await readDispatchBody(c);
785823
}
786824

787-
const queryParams: Record<string, any> = {};
788-
const url = new URL(c.req.url);
789-
url.searchParams.forEach((val, key) => { queryParams[key] = val; });
825+
// [#23214] A repeated name stays an array (`readDispatchQuery`).
826+
const queryParams = readDispatchQuery(c);
790827

791828
const result = await dispatcher.dispatch(method, subPath, body, queryParams, { request: c.req.raw }, prefix);
792829
return toResponse(c, result);

0 commit comments

Comments
 (0)