Skip to content

Commit 2f293b2

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-23307-mail-attachments
2 parents 3a2884e + cb9841a commit 2f293b2

24 files changed

Lines changed: 1923 additions & 8 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/runtime': minor
3+
---
4+
5+
fix(runtime)!: the runtime dispatcher's `DELETE /data/:object/:id` honours `?expectedVersion` and `If-Match` as its `RestServer` twin does, so a delete carrying a stale version is refused instead of applied
6+
7+
`RestServer`'s `DELETE /data/:object/:id` reads an optimistic-concurrency token from `?expectedVersion`, else from the `If-Match` header, refuses the query name supplied more than once, and hands the token to the protocol's `deleteData`, which refuses a delete whose record has moved past it. The runtime dispatcher's `/data` domain read neither, and its shared `callData` delete arm forwarded only the object and the id. So on a host whose `/data` routes the dispatcher serves (a `createHonoApp` host), a delete carrying a stale token deleted the record and answered `200`, the same answer a delete carrying no token gets. The domain now reads the token the way `RestServer` does and `callData` forwards it to `deleteData`.
8+
9+
Clause-②: no (narrowing)
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is added, removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is the accept set of one runtime dispatcher route: a delete whose version token is stale, repeated, or sent to a host that cannot judge it is now refused where it was applied, and 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: the package publishes (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). -->
12+
13+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
14+
15+
What changes for you, wherever the runtime dispatcher serves `DELETE /data/:object/:id`. Where `@objectstack/rest` is mounted it serves this route itself and already answered each of these, so the standalone server's answers do not move.
16+
17+
- FROM a delete with a stale `?expectedVersion` or `If-Match` that deleted the record and answered `200`, TO a refusal with the record kept: the protocol's `409 CONCURRENT_UPDATE`, thrown out of `dispatch()` for the host's exit to render, as every other refusal `deleteData` throws is. A `@objectstack/hono` `createHonoApp` host still renders that refusal as `500` (the record is kept): its catch-all reads only an error's `statusCode`, and rendering the declared `409` is a separate fix.
18+
- FROM a repeated `?expectedVersion` that was ignored (the record deleted, `200`), TO `400 VALIDATION_ERROR` in the dispatcher's envelope with the sentence `RestServer` answers ("The "expectedVersion" query parameter was supplied N times…"), before the protocol is asked. A one-element array is read as the one token it encodes. On a host whose adapter hands the dispatcher only the last value of a repeated query name (the `@objectstack/hono` `createHonoApp` catch-all, until that adapter keeps repeats), the last `?expectedVersion` is judged as the token.
19+
- FROM a delete carrying a token on a host whose protocol has no `deleteData` (the ObjectQL fallback), which deleted unguarded, TO `501 NOT_IMPLEMENTED`, before anything is read or deleted. A delete with no token reaches the fallback as before.
20+
21+
To delete over a version you read, send that version (the record's `updated_at`) once, as `?expectedVersion` or `If-Match`; `?expectedVersion` is judged when both are sent. A delete with no token is unchanged. The `@objectstack/client` SDK's `data.delete(object, id, { ifMatch })` sends `If-Match`, so an SDK caller that passes `ifMatch` now gets, through the dispatcher, the guard it asked for.

‎.claude/skills/pm-dispatch/references/filing-gate.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@
3737
- REST/数据门收窄亦算;措辞、文档、账本、CLI 旗、lint/verify 导出、门不变的服务行为不算。
3838
- 此类任何车道皆 p1 + 在研大版 `target:`,可退役键的条件卡同档;维护者裁决,本节余条不压。
3939
- 分诊轮的维护者清单点名等下个 `next.N` 的 objectui 卡数,堆积即请切版。
40+
- 此类的消费方跟进卡(objectui 类型镜像、渲染器、Studio)缺它即坏用户才 p1,余者守本档。
41+
- 坏用户 = 设计器系统性写出协议拒收的元数据,或协议声明的块/键渲染为空;纯镜像不算。
42+
- 此类的跟进卡全部列入消费方新大版的首发卡,赶在该版发布前落地。
4043
- 清单项三态 ①:项会 fail ⇒ 断,继承其 `priority`;⛔「有人在等」不是定级判据。
4144
- ② 项的标题/场景断言该能力而 steps 到不了那条路径 ⇒ 补步骤并继承该项档 ⛔ 不降 p3。
4245
- ③ 无项断言该能力 ⇒ 缺项补账本、北极星第 2 条 p3;②最常见,`area:*` 继承后成规模。

‎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+
});

0 commit comments

Comments
 (0)