You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(spec): publish the typed hook ctx.api face — HookApi / HookObjectApi / HookQuery (#19067)
Fixes#18163
Clause-②: yes (widening)
Executes batch #147 item 1, letter A — maintainer 「其他同意」
2026-09-17T14:01Z.
`@objectstack/spec/data` now exports the typed hook `ctx.api` face, so a
metadata app's `*.hook.ts` imports the platform's type instead of
hand-declaring one. Nine additive exported names, zero runtime, no
removal and no signature change.
`HookApi` · `HookObjectApi` · `HookQuery` · `HookCountQuery` ·
`HookUpdateOptions` · `HookDeleteOptions` · `HookUpdateDoc` · `HookDoc`
· `HookDriverPassthroughOptions`
```ts
import type { HookApi } from '@objectstack/spec/data';
const api = ctx.api as HookApi | undefined;
if (!api) return;
const owner = await api.object('user').findOne({ where: { id: ctx.input.owner } });
```
## Measured at the engine seam, not from any app's copy
The ruling's third prescription. Located by declaration site, never by a
mention:
| fact | declaration site |
|:---|:---|
| `ctx.api` is built per dispatch by `ObjectQL.buildHookApi` and is a
`ScopedContext` | `packages/objectql/src/engine.ts:4716` |
| `ScopedContext.object(name)` returns an `ObjectRepository` |
`packages/objectql/src/engine.ts:15625` |
| the repository's real method set, and that it injects `context` after
the caller's spread | `packages/objectql/src/engine.ts:15420-15535` |
| the engine's per-method legal option keys |
`packages/objectql/src/engine.ts:519-535` |
| the alias table and the fold, `filter` to `where` and `top` to `limit`
| `packages/spec/src/data/data-engine.zod.ts:517,551` |
| the throw on a slot whose spellings disagree |
`packages/objectql/src/engine.ts:792-799` |
| the type `ctx.api` already carries — `IScopedContext`, from
`@objectstack/spec/contracts` |
`packages/spec/src/contracts/scoped-context.ts:208` |
**What the fold actually does**, which is finer than "refuses both":
redundant spellings that are deep-equal collapse into `where` in
silence; spellings that carry different values are irreconcilable and
the engine throws, naming both. So `{ where, filter }` is a coin toss
decided by whether the two happen to agree. Omitting the alias key makes
it neither — `TS2353` at the authoring site.
The same measurement carries `top`, the OData alias of `limit`: same
slot table, same throw on a value disagreement, no expressive power of
its own. Extending the ruling's `filter` instruction to `top` is this
PR's reading of the same rule and is flagged in the file's own docblock
as the one place contract review should decide whether the type should
be wider than the ruling's letter.
## Not a second dialect of `IScopedContext`
`contracts/scoped-context.ts` stays the CHECKED IMPLEMENTATION contract
— `ScopedContext` and `ObjectRepository` carry `implements` clauses
against it, and its query bags are deliberately loose, for the reason
that file argues at length. This is the authoring half of the same seam.
They cannot drift because every option shape here is an `Omit` or `Pick`
over the very `Engine*Options` schemas the engine's own legal-key sets
are pinned against (`engine-unknown-option.test.ts`), and
`hook-api.test.ts` pins `HookApi` as assignable to `IScopedContext` in
both the context and the repository position — so `ctx.api as HookApi`
stays a direct cast, never `as unknown as`.
Deliberately absent, each with its reason in the docblock: `context`
(injected and discarded), the `cursor` / `distinct` / `upsert`
tombstones, `sudo()` (the #5945 exclusion stands — `Hook.runAs:
'system'` is the declared way to run elevated), and `aggregate` /
`execute` / `create` / `deleteById`. `count` is the one shape without
the driver pass-through keys, because the engine forwards no bag on that
method and rejects them there — engine behaviour no document states.
## Verification, all at `d3895054b2`
**Reverse verification from a consumer's vantage** — a throwaway probe
in a package that resolves `@objectstack/spec/data` through the exports
map into the BUILT `dist`, run in two legs and then removed (worktree
confirmed clean):
```
LEG A probe carries `filter` beside `where` tsc exit 2
os-hookapi-consumer-probe.ts(5,62): error TS2353: Object literal may only
specify known properties, and 'filter' does not exist in type 'HookQuery'.
LEG B same probe, alias key removed tsc exit 0
```
On-disk proof was taken per leg (1 occurrence of the mutated text, then
0), so neither leg is a no-op.
- `pnpm --filter @objectstack/spec typecheck` — exit 0. Three programs:
the build config, `tsconfig.scripts.json`, and `check:test-typecheck`,
which is what compiles the test layer. The six `@ts-expect-error` pins
in `hook-api.test.ts` are therefore real: an unused directive is itself
an error, so a directive that stopped catching anything turns this red.
- `pnpm --filter @objectstack/spec test` — 492 files, 14311 tests, all
pass.
- `pnpm --filter @objectstack/spec check:generated` — all 16 artifacts
up to date.
- `pnpm lint` repo-wide (`eslint . --no-inline-config`) — exit 0, no
narrowing claimed.
- Gate families derived with `scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack` and reconciled with `--ran`: **83 derived,
79 run green, 4 NOT MEASURED.** The four are
`check:doc-formula-expressions`, `check:dual-build-cjs-loads`,
`check:lean-entry-closure` and `check:type-check-debt`, each exiting **3
— PREREQUISITE NOT MET**, which is the code those gates use for "nothing
was measured". All four need the whole workspace built (`turbo run
build` over every package); this diff's own dependency closure is empty,
since `packages/spec` has no workspace dependencies. CI builds fresh and
runs all four.
`origin/main` moved onto the same three generated artifacts and onto
`data/driver.zod.ts`, which this file reads for the pass-through keys,
so the merge went through `scripts/pm/os-regen-merge.sh` and the
regeneration is its own commit. Both sides survive in the regenerated
artifacts: this branch's nine `Hook*` entries and main's
`driverSupportsTransactions`.
## Acceptance notes
- **The card's own grep is not a reading, and the premise is still
true.** `grep -rhoE "export (type|interface)
(HookApi|HookContext|HookObjectApi)" ... dist/data/*.d.ts` returns zero
for `HookContext` too — a symbol that has always been exported from that
entry point. That glob reaches exactly one file, `dist/data/index.d.ts`,
which is a renamed re-export barrel (`export { k as HookContext } from
'../datasource.zod-...js'`); every declaration lives in a hashed chunk
one directory up, outside the glob. Re-derived with a corrected
instrument that counts the barrel's export bindings: `HookContext` 1,
`EngineQueryOptions` 1 (positive controls), `HookApi` / `HookObjectApi`
/ `HookQuery` 0 across the whole published `dist` and 0 across every
package source. So the gap was real; the instrument that found it could
not have told.
- *noted, not filed:* nothing pins that the CLASS `ScopedContext`
satisfies `HookApi`. `packages/spec` must not depend on
`packages/objectql`, so that leg belongs beside
`hook-input-shape-contract.test.ts` in objectql, which this card's file
surface excludes. Carrier: whoever next edits `ScopedContext` or
`ObjectRepository` is in `packages/objectql/src/engine.ts`, where the
pin would live, and the reference app's follow-up card the ruling names
is the other side of the same check.
- *noted, not filed:* `IScopedContext` is reachable only from
`@objectstack/spec/contracts`, so an author who wants to name the
declared type of `ctx.api` alongside `HookApi` imports from two entry
points. Carrier: the same follow-up card, which is the first consumer to
feel it.
- *noted, not filed:* `EngineTransactionInfo` and
`EngineTransactionOptions` are referenced by `HookApi.transaction` but
are not nameable from `./data`. Structural use needs no name and
`check:entry-nameability` passes, so this is an observation, not a gap.
Carrier: none — no consumer needs to spell them.
## NOT MEASURED — say it plainly
The ruling names hotcrm's `src/objects/_hook-api.ts` as the acceptance
fixture: it must type-check against this export with its own copy
deleted. **That leg was not run.** hotcrm is not a repository this
session can reach, and the ruling is explicit that its file is the
fixture and not the source of truth, so nothing here was written from
the card's quoted excerpts of it. The reverse verification above is the
closest reachable stand-in: a real consumer, resolving through the
published exports map into the built `.d.ts`, refusing `filter` and
accepting the canonical shape. It is not the fixture, and it is not
claimed to be.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
`@objectstack/spec/data` now exports the typed hook `ctx.api` face — `HookApi`, `HookObjectApi`, `HookQuery`, `HookCountQuery`, `HookUpdateDoc`, `HookUpdateOptions`, `HookDeleteOptions`, `HookDoc` and `HookDriverPassthroughOptions` — so a metadata app's `*.hook.ts` imports the platform's type instead of hand-declaring one (#18163). The same entry additionally re-exports `EngineTransactionInfo` and `EngineTransactionOptions`, which its public declarations reference structurally: without them a consumer that imports only `@objectstack/spec/data` and emits declarations answers `TS2883: The inferred type ... cannot be named without a reference to ...`. Type-only re-exports of the declarations `@objectstack/spec/contracts` already publishes, not second declarations.
The platform already implemented this surface; it just never published a type an app could import, so every app re-derived the engine's option vocabulary in a copy that drifts the moment the engine moves. The reference third-party app carried ~2,358 authored tokens of one in a single file, imported by 17 hook files.
16
+
17
+
-**The query shape is `where`-only — there is no `filter` key, deliberately.**`RPC_QUERY_ALIAS_SLOTS` declares `filter` as the alias of `where` (and `top` as the alias of `limit`); every engine entry point folds the `where` slot, collapsing redundant identical spellings and REFUSING the slot when the two spellings carry different values. So `{ where, filter }` is silent when they happen to agree and a runtime throw when they do not. Omitting the alias keys makes it neither: `TS2353: 'filter' does not exist in type 'HookQuery'`, at the authoring site.
18
+
-**Not a second dialect of `IScopedContext`.**`contracts/scoped-context.ts` stays the CHECKED IMPLEMENTATION contract ObjectQL's `ScopedContext` and `ObjectRepository` carry `implements` clauses against, with its deliberately loose `Record<string, unknown>` bags. This is the authoring half of the same seam: `HookApi` is assignable to `IScopedContext`, so `ctx.api as HookApi` stays a direct cast, and nothing about the older contract changes.
19
+
-**Every option shape is DERIVED, not transcribed.** Each is an `Omit`/`Pick` over the `Engine*Options` schemas that the engine's own per-method legal-key sets are pinned against, so a key added to a schema reaches the published type in the same run it reaches the engine's accepted set. `count` is the one shape without the driver pass-through keys, because the engine forwards no bag on that method and rejects them there — engine behaviour no document states, and exactly what a hand-written copy gets wrong.
20
+
-**What is deliberately absent, each for a stated reason**: `context` (the repository injects it and discards a caller's), the `cursor` / `distinct` / `upsert` tombstones, `sudo()` (the #5945 exclusion stands — `Hook.runAs: 'system'` is the declared way to run elevated), and `aggregate` / `execute` / `create` / `deleteById`.
21
+
22
+
Additive only: eleven new exported names from `./data` (nine new declarations plus two type-only re-exports), no removal and no signature change, so nothing an existing consumer imports moves.
0 commit comments