Skip to content

Commit 22847cb

Browse files
committed
feat(spec)!: HookContext.api 从 z.unknown() 收窄为最小 IScopedContext (#5945)
维护者裁决 C:`HookContext.api` 不再是 `z.unknown()`,改为指向 `packages/spec/src/contracts/scoped-context.ts` 里新增的 `IScopedContext` / `IScopedObjectRepository`(与 IDataEngine / IObjectQLEngine 同层同风格,含 evidence-bar 模块头)。 声明面 = 语料库实测的调用点,不多不少: IScopedContext object(name) + transaction(cb, opts?) IScopedObjectRepository find / findOne / count / insert / update / updateById upsert / delete / aggregate / create 只出现在文档的方法表与能力表里、没有任何 调用点(表格不过编译器);sudo() 的三个调用方全部把值持成 any 且它是提权动作。 一律不声明,等到有调用点再按同一条规则加 —— 与 IDataEngine (#4251) 同款纪律。 运行时零变化:Zod 侧仍是 z.unknown(),收窄是静态 cast(与 object.zod.ts 的 ObjectCapabilities.apiMethods 同一惯用法)。z.custom 试过,它让 HookContext 在 JSON Schema 里不可表达 —— gen:schema 直接不再产出 json-schema/data/HookContext.json, 会在下次 gen:docs 抹掉参考页(#2978),故不用。接受的值、JSON Schema、生成的参考页 行全部不变,只有 .describe() 文案改了。 漂移由编译器盯着:objectql 的 ScopedContext / ObjectRepository 声明 implements。 实测把 updateById 改名,objectql 的 tsc 在 implements 处 + 五个 hook 派发点同时报错。 content/docs/kernel/runtime-services/examples.mdx 里那段 os:check 块删掉了自建的 `type CrossObjectApi` + `ctx.api as CrossObjectApi`,改为直接读契约。 scripts/engine-double-contract.baseline.json 新增两条 EXEMPT:两个新 fake 是 IScopedObjectRepository 的类型符合性见证,不是 engine double(scoped repository 是 该门二分法没有的第三种);spec 也无法 import objectql/metadata-core(依赖反转)。 dormancy 已用 stderr 探针实测(对照组会打印,被测的 update 全程静默)。 Fixes #5945 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY
1 parent 773f80a commit 22847cb

12 files changed

Lines changed: 811 additions & 23 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
feat(spec)!: `HookContext.api``z.unknown()` 收窄为 `IScopedContext`,文档教的第一个 hook 终于编译得过 (#5945)
6+
7+
`HookContext.api` 是文档教的**主数据通道**,而它的类型是 `unknown`。于是所有文档、技能、示例里那个标准写法:
8+
9+
```ts
10+
handler: async (ctx: HookContext) => {
11+
const users = ctx.api.object('user'); // error TS18046: 'ctx.api' is of type 'unknown'.
12+
}
13+
```
14+
15+
一行都编译不过 —— 包括 `hook.zod.ts``api` 这个键**自己 JSDoc 上的示例**。语料库全在这么教(`skills/objectstack-data/references/data-hooks.md``content/docs/automation/hooks.mdx``content/docs/api/error-handling-server.mdx``content/docs/kernel/runtime-services/*`),这些块都没进 `os:check`,所以从来没有一道门看见过。唯一进了 `os:check` 的那块(`runtime-services/examples.mdx`)也只能靠在示例里自建一个 `type CrossObjectApi = …``ctx.api as CrossObjectApi` 才编得过 —— 每个消费方各 cast 一遍、cast 的形状无人校验,正是 contract-first 要终结的方向。
16+
17+
**本次落地维护者裁决 C**`packages/spec/src/contracts/` 新增 `IScopedContext` / `IScopedObjectRepository`(与 `IDataEngine` / `IObjectQLEngine` 同层同风格),`HookContext.api` 的 TS 类型指向它。
18+
19+
**声明面 = 语料库实测的调用点**,不多也不少(证据表在 PR 正文,逐条 file:line):
20+
21+
- `IScopedContext``object(name)` + `transaction(cb, opts?)`
22+
- `IScopedObjectRepository``find` / `findOne` / `count` / `insert` / `update` / `updateById`
23+
24+
`upsert` / `delete` / `aggregate` / `create` 只出现在文档的**方法表与能力表**里、从没有一处调用点(表格不过编译器),`sudo()` 的三个调用方全部把值持成 `any` 且它是提权动作 —— 一律不声明,等到有调用点再按同一条规则加。这与 `IDataEngine` 当年(#4251)确立的「有证据才声明」是同一条纪律。
25+
26+
**运行时零变化**:Zod 侧仍是 `z.unknown()``z.custom` 会让 `HookContext` 在 JSON Schema 里不可表达,`gen:schema` 直接不再产出 `json-schema/data/HookContext.json`,进而在下次 `gen:docs` 抹掉它的参考页 —— 实测过,不是推测)。收窄是纯静态的:接受的值、JSON Schema、生成的参考页行全部逐字节不变,只有 `.describe()` 文案改了。
27+
28+
**漂移由编译器盯着**`packages/objectql``ScopedContext` / `ObjectRepository` 声明了 `implements`,契约与引擎实际绑定的那个对象再也不能各说各话(把 `updateById` 改个名,objectql 的 `tsc` 会在 `implements` 处和五个 hook 派发点同时报错 —— 实测过)。
29+
30+
**FROM → TO —— 什么代码需要改**
31+
32+
读取端只会变宽,原来编译得过的读法一行都不用动(原来根本没有能编译过的读法)。两类**写入端**可能要改:
33+
34+
```ts
35+
// 1. 自建 cast 的消费方 —— 删掉 cast 即可,`ctx.api` 现在自带类型
36+
-const api = ctx.api as CrossObjectApi;
37+
-const account = await api.object('crm_account').findOne({ where: { id } });
38+
+const account = await ctx.api?.object('crm_account').findOne({ where: { id } });
39+
40+
// 2. 构造 HookContext 字面量的测试替身 —— `api` 现在必须是 IScopedContext 形状(或省略)
41+
const ctx: HookContext = {
42+
object: 'account', event: 'beforeInsert', input: {}, ql: {},
43+
- api: whateverStub,
44+
+ api: undefined, // 或一个带 object(name) / transaction(cb) 的替身
45+
};
46+
```
47+
48+
`api` **仍是可选的**`buildHookApi` 在全部五个派发点都会设置它,但改成必填会开始拒绝今天能过的部分上下文(没有活引擎时构造的 context),所以读法是 `ctx.api?.object(…)`

content/docs/kernel/runtime-services/examples.mdx

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -123,17 +123,6 @@ hook adds is the **business** rule the engine cannot know.
123123
```ts
124124
import { defineHook, type HookContext } from '@objectstack/spec/data';
125125

126-
/**
127-
* The one call this hook makes on `ctx.api`. The contract declares
128-
* `HookContext.api` opaque (`api: unknown`) because the object the engine binds is
129-
* ObjectQL's `ScopedContext`, so a typed handler names the slice it uses.
130-
*/
131-
type CrossObjectApi = {
132-
object(name: string): {
133-
findOne(query: { where: Record<string, unknown> }): Promise<{ credit_limit?: number } | null>;
134-
};
135-
};
136-
137126
export const ContractWithinCreditLimit = defineHook({
138127
name: 'contract_within_credit_limit',
139128
object: 'contract',
@@ -142,10 +131,11 @@ export const ContractWithinCreditLimit = defineHook({
142131
const accountId = ctx.input.account_id;
143132
if (typeof accountId !== 'string') return;
144133

145-
const api = ctx.api as CrossObjectApi;
146-
const account = await api.object('crm_account').findOne({ where: { id: accountId } });
134+
// `ctx.api` is typed (`IScopedContext`) — no cast. It is optional because a
135+
// context can be built without a live engine, so reach it with `?.`.
136+
const account = await ctx.api?.object('crm_account').findOne({ where: { id: accountId } });
147137

148-
const limit = account?.credit_limit ?? 0;
138+
const limit = Number(account?.credit_limit ?? 0);
149139
const amount = Number(ctx.input.amount ?? 0);
150140
if (limit > 0 && amount > limit) {
151141
throw new Error('VALIDATION_FAILED: contract amount exceeds the account credit limit');

content/docs/references/data/hook.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ const result = HookContextSchema.parse(data);
4141
| **provenance** | `{ flowRunId?: string; attributedUserId?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) |
4242
| **transaction** | `any` | optional | Database transaction handle |
4343
| **ql** | `any` || ObjectQL Engine Reference |
44-
| **api** | `any` | optional | Cross-object data access (ScopedContext) |
44+
| **api** | `any` | optional | Cross-object data access (IScopedContext — `object(name)` + `transaction(cb)`) |
4545
| **user** | `{ id?: string; name?: string; email?: string; organizationId?: string }` | optional | Current user info shortcut |
4646

4747

packages/objectql/src/engine.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ import {
2929
} from '@objectstack/spec/system';
3030
import { ExecutionContext, ExecutionContextInput, ExecutionContextSchema } from '@objectstack/spec/kernel';
3131
import type { FlowFunctionEffect } from '@objectstack/spec/automation';
32+
// Imported from spec directly rather than through `@objectstack/core`'s
33+
// re-export block: that block is labelled backward-compatibility, and this
34+
// contract is new (#5945).
35+
import type { IScopedContext, IScopedObjectRepository } from '@objectstack/spec/contracts';
3236
import {
3337
IDataDriver,
3438
IDataEngine,
@@ -7305,7 +7309,21 @@ export class ObjectQL implements IObjectQLEngine {
73057309
* and convenience aliases (create, updateById, deleteById) matching
73067310
* the @objectql/core ObjectRepository API.
73077311
*/
7308-
export class ObjectRepository {
7312+
/**
7313+
* A repository bound to one object and one execution context — what
7314+
* `ScopedContext.object(name)` returns, and what a hook reaches as
7315+
* `ctx.api.object(name)`.
7316+
*
7317+
* `implements IScopedObjectRepository` (#5945): the six members that contract
7318+
* declares are the ones the documentation corpus is measured to CALL, and the
7319+
* `implements` clause is what keeps the two from drifting — before it, the
7320+
* only descriptions of this face were the private slices each consumer
7321+
* hand-rolled (`type CrossObjectApi = …`), which nothing checked. The class
7322+
* stays WIDER than the contract on purpose (`create`, `delete`, `deleteById`,
7323+
* `aggregate`, `execute`); `implements` allows that, and those members join the
7324+
* contract when a call site turns up to justify them.
7325+
*/
7326+
export class ObjectRepository implements IScopedObjectRepository {
73097327
constructor(
73107328
private objectName: string,
73117329
private context: ExecutionContextInput,
@@ -7398,12 +7416,18 @@ export class ObjectRepository {
73987416

73997417
/**
74007418
* Scoped execution context with object() accessor.
7401-
*
7419+
*
74027420
* Provides identity (userId, tenantId/spaceId, roles),
74037421
* repository access via object(), privilege escalation via sudo(),
74047422
* and transactional execution via transaction().
7423+
*
7424+
* `implements IScopedContext` (#5945) — this class IS `HookContext.api`, built
7425+
* per dispatch by {@link ObjectQL.buildHookApi}. The contract declares the two
7426+
* members hooks reach (`object`, `transaction`); `sudo()`, the discrete
7427+
* begin/commit/rollback trio and the identity getters stay off it, so this
7428+
* class is deliberately wider than what it implements.
74057429
*/
7406-
export class ScopedContext {
7430+
export class ScopedContext implements IScopedContext {
74077431
constructor(
74087432
private executionContext: ExecutionContextInput,
74097433
private engine: IDataEngine

packages/spec/api-surface/contracts.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@
132132
"IRlsMembershipResolver (interface)",
133133
"ISchemaDiffService (interface)",
134134
"ISchemaDriver (interface)",
135+
"IScopedContext (interface)",
136+
"IScopedObjectRepository (interface)",
135137
"ISearchService (interface)",
136138
"ISecurityService (interface)",
137139
"ISeedLoaderService (interface)",

packages/spec/src/contracts/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
export * from './logger.js';
1111
export * from './data-engine.js';
1212
export * from './objectql-engine.js';
13+
// The hook-facing slice of the engine: what `HookContext.api` is (#5945).
14+
export * from './scoped-context.js';
1315
export * from './data-driver.js';
1416
export * from './http-server.js';
1517
export * from './service-registry.js';

0 commit comments

Comments
 (0)