Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .changeset/hook-context-api-scoped-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
"@objectstack/spec": major
---

feat(spec)!: `HookContext.api` 从 `z.unknown()` 收窄为 `IScopedContext`,文档教的第一个 hook 终于编译得过 (#5945)

`HookContext.api` 是文档教的**主数据通道**,而它的类型是 `unknown`。于是所有文档、技能、示例里那个标准写法:

```ts
handler: async (ctx: HookContext) => {
const users = ctx.api.object('user'); // error TS18046: 'ctx.api' is of type 'unknown'.
}
```

一行都编译不过 —— 包括 `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 要终结的方向。

**本次落地维护者裁决 C**:`packages/spec/src/contracts/` 新增 `IScopedContext` / `IScopedObjectRepository`(与 `IDataEngine` / `IObjectQLEngine` 同层同风格),`HookContext.api` 的 TS 类型指向它。

**声明面 = 语料库实测的调用点**,不多也不少(证据表在 PR 正文,逐条 file:line):

- `IScopedContext`:`object(name)` + `transaction(cb, opts?)`
- `IScopedObjectRepository`:`find` / `findOne` / `count` / `insert` / `update` / `updateById`

`upsert` / `delete` / `aggregate` / `create` 只出现在文档的**方法表与能力表**里、从没有一处调用点(表格不过编译器),`sudo()` 的三个调用方全部把值持成 `any` 且它是提权动作 —— 一律不声明,等到有调用点再按同一条规则加。这与 `IDataEngine` 当年(#4251)确立的「有证据才声明」是同一条纪律。

**运行时零变化**:Zod 侧仍是 `z.unknown()`(`z.custom` 会让 `HookContext` 在 JSON Schema 里不可表达,`gen:schema` 直接不再产出 `json-schema/data/HookContext.json`,进而在下次 `gen:docs` 抹掉它的参考页 —— 实测过,不是推测)。收窄是纯静态的:接受的值、JSON Schema、生成的参考页行全部逐字节不变,只有 `.describe()` 文案改了。

**漂移由编译器盯着**:`packages/objectql` 的 `ScopedContext` / `ObjectRepository` 声明了 `implements`,契约与引擎实际绑定的那个对象再也不能各说各话(把 `updateById` 改个名,objectql 的 `tsc` 会在 `implements` 处和五个 hook 派发点同时报错 —— 实测过)。

**FROM → TO —— 什么代码需要改**

读取端只会变宽,原来编译得过的读法一行都不用动(原来根本没有能编译过的读法)。两类**写入端**可能要改:

```ts
// 1. 自建 cast 的消费方 —— 删掉 cast 即可,`ctx.api` 现在自带类型
-const api = ctx.api as CrossObjectApi;
-const account = await api.object('crm_account').findOne({ where: { id } });
+const account = await ctx.api?.object('crm_account').findOne({ where: { id } });

// 2. 构造 HookContext 字面量的测试替身 —— `api` 现在必须是 IScopedContext 形状(或省略)
const ctx: HookContext = {
object: 'account', event: 'beforeInsert', input: {}, ql: {},
- api: whateverStub,
+ api: undefined, // 或一个带 object(name) / transaction(cb) 的替身
};
```

`api` **仍是可选的**:`buildHookApi` 在全部五个派发点都会设置它,但改成必填会开始拒绝今天能过的部分上下文(没有活引擎时构造的 context),所以读法是 `ctx.api?.object(…)`。
18 changes: 4 additions & 14 deletions content/docs/kernel/runtime-services/examples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -123,17 +123,6 @@ hook adds is the **business** rule the engine cannot know.
```ts
import { defineHook, type HookContext } from '@objectstack/spec/data';

/**
* The one call this hook makes on `ctx.api`. The contract declares
* `HookContext.api` opaque (`api: unknown`) because the object the engine binds is
* ObjectQL's `ScopedContext`, so a typed handler names the slice it uses.
*/
type CrossObjectApi = {
object(name: string): {
findOne(query: { where: Record<string, unknown> }): Promise<{ credit_limit?: number } | null>;
};
};

export const ContractWithinCreditLimit = defineHook({
name: 'contract_within_credit_limit',
object: 'contract',
Expand All @@ -142,10 +131,11 @@ export const ContractWithinCreditLimit = defineHook({
const accountId = ctx.input.account_id;
if (typeof accountId !== 'string') return;

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

const limit = account?.credit_limit ?? 0;
const limit = Number(account?.credit_limit ?? 0);
const amount = Number(ctx.input.amount ?? 0);
if (limit > 0 && amount > limit) {
throw new Error('VALIDATION_FAILED: contract amount exceeds the account credit limit');
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/hook.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const result = HookContextSchema.parse(data);
| **provenance** | `{ flowRunId?: string; attributedUserId?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) |
| **transaction** | `any` | optional | Database transaction handle |
| **ql** | `any` | ✅ | ObjectQL Engine Reference |
| **api** | `any` | optional | Cross-object data access (ScopedContext) |
| **api** | `any` | optional | Cross-object data access (IScopedContext — `object(name)` + `transaction(cb)`) |
| **user** | `{ id?: string; name?: string; email?: string; organizationId?: string }` | optional | Current user info shortcut |


Expand Down
30 changes: 27 additions & 3 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ import {
} from '@objectstack/spec/system';
import { ExecutionContext, ExecutionContextInput, ExecutionContextSchema } from '@objectstack/spec/kernel';
import type { FlowFunctionEffect } from '@objectstack/spec/automation';
// Imported from spec directly rather than through `@objectstack/core`'s
// re-export block: that block is labelled backward-compatibility, and this
// contract is new (#5945).
import type { IScopedContext, IScopedObjectRepository } from '@objectstack/spec/contracts';
import {
IDataDriver,
IDataEngine,
Expand Down Expand Up @@ -7305,7 +7309,21 @@ export class ObjectQL implements IObjectQLEngine {
* and convenience aliases (create, updateById, deleteById) matching
* the @objectql/core ObjectRepository API.
*/
export class ObjectRepository {
/**
* A repository bound to one object and one execution context — what
* `ScopedContext.object(name)` returns, and what a hook reaches as
* `ctx.api.object(name)`.
*
* `implements IScopedObjectRepository` (#5945): the six members that contract
* declares are the ones the documentation corpus is measured to CALL, and the
* `implements` clause is what keeps the two from drifting — before it, the
* only descriptions of this face were the private slices each consumer
* hand-rolled (`type CrossObjectApi = …`), which nothing checked. The class
* stays WIDER than the contract on purpose (`create`, `delete`, `deleteById`,
* `aggregate`, `execute`); `implements` allows that, and those members join the
* contract when a call site turns up to justify them.
*/
export class ObjectRepository implements IScopedObjectRepository {
constructor(
private objectName: string,
private context: ExecutionContextInput,
Expand Down Expand Up @@ -7398,12 +7416,18 @@ export class ObjectRepository {

/**
* Scoped execution context with object() accessor.
*
*
* Provides identity (userId, tenantId/spaceId, roles),
* repository access via object(), privilege escalation via sudo(),
* and transactional execution via transaction().
*
* `implements IScopedContext` (#5945) — this class IS `HookContext.api`, built
* per dispatch by {@link ObjectQL.buildHookApi}. The contract declares the two
* members hooks reach (`object`, `transaction`); `sudo()`, the discrete
* begin/commit/rollback trio and the identity getters stay off it, so this
* class is deliberately wider than what it implements.
*/
export class ScopedContext {
export class ScopedContext implements IScopedContext {
constructor(
private executionContext: ExecutionContextInput,
private engine: IDataEngine
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/api-surface/contracts.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@
"IRlsMembershipResolver (interface)",
"ISchemaDiffService (interface)",
"ISchemaDriver (interface)",
"IScopedContext (interface)",
"IScopedObjectRepository (interface)",
"ISearchService (interface)",
"ISecurityService (interface)",
"ISeedLoaderService (interface)",
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/src/contracts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
export * from './logger.js';
export * from './data-engine.js';
export * from './objectql-engine.js';
// The hook-facing slice of the engine: what `HookContext.api` is (#5945).
export * from './scoped-context.js';
export * from './data-driver.js';
export * from './http-server.js';
export * from './service-registry.js';
Expand Down
Loading
Loading