Repository navigation
Commit e26cf9a
feat(spec): a hook can tell the person who saved a record what the save set in motion (#28247)
domain:spec#2 · os-tesla · session_01L9qibx5r5nhDMwpRs4SL6D
<!-- claude-projects-attribution: {"github_login":"os-tesla"} -->
_Requested by **Tesla** · [project
thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao4JBeNUskLRfn2ozyBi3RQF)_
Fixes #28000
Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: follow-up #28253
(skills/objectstack-data/rules/lifecycle.md, to land with the server
half #28251)
## 维护者速读
- 改了什么:单条记录的新建和更新应答多了一个只读成员
`notice`:一条由作者写的提示,告诉保存的人这次保存触发了什么(例如「已提交经理审批」),信息级,和 `warnings`
分开。钩子通过本次写入自带的 `onNotice` 监听器设置它,`createData` / `updateData`
把最后一条非空消息答回去;客户端 SDK 同步声明这个键。
- 为什么改:钩子或记录触发的流程没法告诉用户保存启动了什么,HotCRM 只能在详情页挂一个 `record:alert` 横幅来补。
- 风险与回滚:只是应答里新增的可选键,只回给发起这次请求的人,不落库;嵌套写入的提示不会串到外层。不读这个键的客户端毫无变化,回滚即
revert 本 PR。
- 先看:`packages/spec/src/contracts/data-engine.ts` 的
`onNotice`、`packages/spec/src/api/protocol.zod.ts` 的
`WRITE_ANSWER_NOTICE_DESCRIPTION`、`packages/metadata-protocol/src/protocol.ts`
的 `createData` / `updateData`。
- 两分钟验证:`pnpm --filter @objectstack/objectql exec vitest run
src/protocol-save-notice-answer.test.ts`,预期 14 条全绿。
- 需要你做的:无,席位落地。
## What changes
A `beforeInsert` / `beforeUpdate` / `afterInsert` / `afterUpdate` hook
could not tell the person saving what the save set in motion. The
single-record write answer carried `warnings` (advisory validation-rule
hits) and nothing else a save could say, so HotCRM shows a
`record:alert` banner after the fact. This is the spec half of #28000:
the answer key, and the contract a hook uses to set it.
**Transport.** No new path. It is the one #27703 used: the engine
already hands every hook the caller's own options bag on
`ctx.input.options` (the PHASE rule in `hook.zod.ts`: a `before*`
handler reads it as passed, an `after*` handler after
`buildDriverOptions` widened it, never stripped), and `createData` /
`updateData` already collect in-process listeners from it. This PR adds
one more listener of that family. Without the transport the answer key
would be declared with no producer.
- **Spec (`packages/spec`).**
- `CreateDataResponseSchema` and `UpdateDataResponseSchema` declare an
optional, response-only `notice: string` (non-empty), with one shared
description: one author-written message, info severity, distinct from
`warnings`, the last call wins, omitted when no hook set one, never
stored, never carried from a nested write. `CloneDataResponseSchema`
does not get it: the card names the single-record create and update
answers.
- `WriteObservabilityOptions` (`contracts/data-engine.ts`) declares
`onNotice?: (message: string) => void`. Its docblock says the engine
never calls it, how a hook reaches and calls it (with the typed cast the
platform's own hooks use), that the sandboxed `body` face does not carry
it, the one-message rule, and that it never crosses the wire.
`HookUpdateOptions` names it among the members it inherits.
- `content/docs/references/api/protocol.mdx` and
`authorable-surface/api.json` regenerated (two answer keys each).
- **metadata-protocol.** `createData` and `updateData` pass `onNotice`
and answer `notice` omit-when-absent. The listener keeps the last
message that is a non-empty string; a blank or non-string call sets
nothing and never clears an earlier message. The variable is local to
the request, so no other request's answer can carry it.
- **objectql.** `ENGINE_UPDATE_OPTION_KEYS` admits `onNotice`, because
`engine.update` refuses unknown option keys (`insert` has no such
allowlist). The drift pin in `engine-unknown-option.test.ts` names it.
- **client.** `CreateDataResult` and `UpdateDataResult` declare
`notice?: string`, mirroring the two schemas.
- **Docs.** `content/docs/api/data-api.mdx` (POST / PATCH) and
`content/docs/kernel/contracts/data-engine.mdx`
(`WriteObservabilityOptions`, a new `onNotice` section with an
`os:check` example that `check:skill-examples` type-checks).
- **Changeset.** `.changeset/28000-save-notice-answer.md`:
`@objectstack/spec`, `@objectstack/metadata-protocol`,
`@objectstack/objectql` and `@objectstack/client`, all `minor`, with
both declaration lines.
**The shape, and the alternatives not taken.**
- *A new key, not `warnings` with an `info` severity.* `warnings`
elements are validation findings (`field`, `code`, `rule`) that the
console renders as a problem with the save. A notice reports an outcome,
has no field or rule, and the console shows it with, or in place of, its
success toast. Folding it in would make every reader of `warnings` tell
the two apart by a severity value.
- *One message, not a list.* The card asks for one message, and a save
shows one toast (#27380 row 2's one-toast rule). When more than one call
sets it, the last one wins: an `after*` hook knows more than a `before*`
one about what the save actually started. A list would push the merge
rule onto every client.
- *A plain string, not an object.* Nothing else is known about a notice
today (no field, no link, no severity choice). An object can be
introduced later as a new key if one is needed.
- *The listener on the write's options, not a new `HookContext` member.*
A `HookContext` method needs the engine to bind it at every dispatch
site and the sandbox runner to marshal it, which is the server half the
card files separately. Declaring one here with no producer would be a
declared-never-produced key.
## Tests
- **New contract pins.**
`packages/spec/src/api/write-answer-notice.test.ts` (8): the member is
accepted on both answers and an answer without it stays legal; a list,
an object and an empty string are refused; it sits beside `warnings`,
never folded in; the clone answer does not declare it; the description
is shared and says it is never stored and the last one wins; the
listener type takes one message and may be absent.
- **Transport, real engine + real protocol.**
`packages/objectql/src/protocol-save-notice-answer.test.ts` (14) uses a
real `ObjectQL` and the real `ObjectStackProtocolImplementation`, with
hooks on both surfaces an app reaches the bag from: the raw
`registerHook` envelope and a declarative `handler` bound through
`bindHooksToEngine`. It pins: a `beforeInsert` and an `afterInsert` hook
each set the create answer's `notice`, and the answer conforms; no key
when no hook set one; the last call wins; a blank message sets nothing
and never clears an earlier one; a refused write answers its error;
nothing is stored; a nested write's notice never reaches the outer
answer; a second request is not told the first one's notice;
`beforeUpdate` and `afterUpdate` set the update answer's `notice` and it
conforms; a declarative `handler` reaches the same listener on create
and on update.
- `engine-unknown-option.test.ts`: `onNotice` passes on `update`, and
the drift pin names it.
<details><summary>Package suites and typecheck, on
<code>e7c47912</code></summary>
- `pnpm --filter @objectstack/objectql test`: Test Files 500/500, Tests
9738/9738.
- `pnpm --filter @objectstack/metadata-protocol test`: Test Files 284
passed, 4 skipped; Tests 21860 passed, 26 skipped.
- `pnpm --filter @objectstack/client test`: Test Files 52/52, Tests
682/682.
- `pnpm --filter @objectstack/spec test`: Test Files 760/760, Tests
22573 passed, 1 todo.
- `typecheck` for `@objectstack/spec`, `@objectstack/metadata-protocol`,
`@objectstack/objectql` and `@objectstack/client`: lock VERDICT
command-exit 0.
- Before the merge of `origin/main` (head `7f5907b4`): the spec contract
test 8/8; the objectql transport and option tests 39/39.
</details>
<details><summary>Ablations (one-shot, through
<code>scripts/ablation-replace.mjs</code>, each restored to blob ==
HEAD)</summary>
The transport test resolves `@objectstack/metadata-protocol` through
`dist/`, so each leg rebuilt it and checked the marker with
`ablation-dist-preflight.mjs` (present on the mutation leg, absent on
the restore leg).
| # | Mutation (`metadata-protocol` `protocol.ts`) | Expected | Observed
|
|:--|:--|:--|:--|
| A1 | `createData`: the `onNotice` listener replaced by one that
records nothing | create pins red | 6 failed / 14 (both create hooks,
last-wins, blank, second-request, declarative create); restore 14/14 |
| A2 | `updateData`: the `onNotice` listener replaced by one that
records nothing | update pins red | 3 failed / 14 (beforeUpdate,
afterUpdate, declarative update); restore 14/14 |
| A3 | `createData`: the blank-message guard removed | blank pin red | 1
failed / 14 (the blank case); restore 14/14 |
The `os:check` example in `data-engine.mdx` was mutated once to pass a
number to `onNotice`: `check:skill-examples` went red at that line (exit
1), and green again after the restore.
</details>
## Acceptance notes
- **Only a hook whose handler runs as code can set it today.** A
sandboxed `body` (including a `handler` the build lowers into one, which
is how HotCRM's hooks run) sees `ctx.input.options` as the frozen `{
multi, where }` projection, and a record-triggered flow has no hook
context at all. Giving those two a way to set the notice (a
`HookContext` member the engine binds and the sandbox marshals, and a
flow node) is the server half, a follow-up card.
- **Batch, clone and flow writes do not answer it.** Only `createData`
and `updateData` pass the listener, as the card scopes it. A hook on a
write made by `createManyData` / `batchData`, a clone or a flow node
finds no listener and sets nothing.
- **The console toast** (show `notice` with, or in place of, the
record-named success message in AppContent `crud_success`,
`RecordFormPage` and the inline edit save) is the `domain:ui` follow-up
card.
- **Calling the listener is the hook's own act.** The engine never calls
it, so nothing between the hook and the listener catches. The protocol's
listener only records the message and never throws.
## Verification
Head `e7c47912` (branch merged with `origin/main` at `3739d8d6`, after
#28174 landed; the one later `main` commit touches none of these files).
- Gates were derived with `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` against this diff: 109 families. Each
ran with stdin from `/dev/null`, and each exit code was recorded before
any pipe. Reconciled with `--ran`: **109 derived, 108 run, 1 NOT
MEASURED, 0 unrun, 0 red.**
- NOT MEASURED: `check:dual-build-cjs-loads` (exit 3, needs a whole-repo
`pnpm build`). CI's.
- Two gates first exited 3 and were re-run green once their prerequisite
was in place: `node scripts/check-plugin-teardown-shape.mjs --self-test`
(its pinned control `621a4876` was missing from the shallow clone;
fetched, 48 cases) and `spec check:skill-examples` (needed the client
SDK dist; built, 277 examples type-check). The record carries the
re-runs.
- `pnpm --filter @objectstack/spec check:generated`: after a rebuild of
the merged source, `check:docs` was stale and `--fix` regenerated
`content/docs/references/api/protocol.mdx`; the re-check passed. The
build rewrote `authorable-surface/api.json`; against `main` it adds only
the two `notice` rows.
- Full dogfood: not owed (no `!` in the title, no `(narrowing)` arm).
- The full farm is CI's. The PR's check-runs are the evidence for every
family outside the derived set.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01L9qibyvb1yBsrocHdqS4Ua)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 86cf7ce commit e26cf9a
14 files changed
Lines changed: 497 additions & 9 deletions
File tree
- .changeset
- content/docs
- api
- kernel/contracts
- references/api
- packages
- client/src
- metadata-protocol/src
- objectql/src
- spec
- authorable-surface
- src
- api
- contracts
- data
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
239 | 239 | | |
240 | 240 | | |
241 | 241 | | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
242 | 249 | | |
243 | 250 | | |
244 | 251 | | |
| |||
249 | 256 | | |
250 | 257 | | |
251 | 258 | | |
| 259 | + | |
| 260 | + | |
252 | 261 | | |
253 | 262 | | |
254 | 263 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
327 | 327 | | |
328 | 328 | | |
329 | 329 | | |
330 | | - | |
| 330 | + | |
331 | 331 | | |
332 | 332 | | |
333 | 333 | | |
334 | 334 | | |
335 | 335 | | |
336 | 336 | | |
| 337 | + | |
| 338 | + | |
337 | 339 | | |
338 | 340 | | |
339 | 341 | | |
| |||
360 | 362 | | |
361 | 363 | | |
362 | 364 | | |
| 365 | + | |
363 | 366 | | |
364 | 367 | | |
365 | 368 | | |
| |||
475 | 478 | | |
476 | 479 | | |
477 | 480 | | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
478 | 515 | | |
479 | 516 | | |
480 | 517 | | |
| |||
486 | 523 | | |
487 | 524 | | |
488 | 525 | | |
489 | | - | |
| 526 | + | |
490 | 527 | | |
491 | 528 | | |
492 | 529 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
557 | 557 | | |
558 | 558 | | |
559 | 559 | | |
| 560 | + | |
560 | 561 | | |
561 | 562 | | |
562 | 563 | | |
| |||
3043 | 3044 | | |
3044 | 3045 | | |
3045 | 3046 | | |
| 3047 | + | |
3046 | 3048 | | |
3047 | 3049 | | |
3048 | 3050 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
498 | 498 | | |
499 | 499 | | |
500 | 500 | | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
501 | 509 | | |
502 | 510 | | |
503 | 511 | | |
| |||
560 | 568 | | |
561 | 569 | | |
562 | 570 | | |
| 571 | + | |
| 572 | + | |
| 573 | + | |
| 574 | + | |
| 575 | + | |
| 576 | + | |
| 577 | + | |
| 578 | + | |
563 | 579 | | |
564 | 580 | | |
565 | 581 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
13743 | 13743 | | |
13744 | 13744 | | |
13745 | 13745 | | |
| 13746 | + | |
| 13747 | + | |
| 13748 | + | |
| 13749 | + | |
| 13750 | + | |
| 13751 | + | |
13746 | 13752 | | |
13747 | 13753 | | |
13748 | 13754 | | |
13749 | 13755 | | |
| 13756 | + | |
13750 | 13757 | | |
13751 | 13758 | | |
13752 | 13759 | | |
| |||
13765 | 13772 | | |
13766 | 13773 | | |
13767 | 13774 | | |
| 13775 | + | |
13768 | 13776 | | |
13769 | 13777 | | |
13770 | 13778 | | |
| |||
13917 | 13925 | | |
13918 | 13926 | | |
13919 | 13927 | | |
| 13928 | + | |
| 13929 | + | |
| 13930 | + | |
13920 | 13931 | | |
13921 | 13932 | | |
13922 | 13933 | | |
| |||
13994 | 14005 | | |
13995 | 14006 | | |
13996 | 14007 | | |
| 14008 | + | |
13997 | 14009 | | |
13998 | 14010 | | |
13999 | 14011 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
200 | 200 | | |
201 | 201 | | |
202 | 202 | | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
203 | 207 | | |
204 | 208 | | |
205 | 209 | | |
| |||
263 | 267 | | |
264 | 268 | | |
265 | 269 | | |
266 | | - | |
267 | | - | |
268 | | - | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
269 | 274 | | |
270 | 275 | | |
271 | 276 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
716 | 716 | | |
717 | 717 | | |
718 | 718 | | |
719 | | - | |
| 719 | + | |
720 | 720 | | |
721 | 721 | | |
722 | 722 | | |
| |||
0 commit comments