Skip to content

Commit e26cf9a

Browse files
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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-protocol': minor
4+
'@objectstack/objectql': minor
5+
'@objectstack/client': minor
6+
---
7+
8+
feat(spec): a hook can tell the person who saved a record what the save set in motion
9+
10+
Clause-②: yes
11+
Metadata-protocol: added
12+
13+
A `beforeInsert` / `beforeUpdate` / `afterInsert` / `afterUpdate` hook had no way to tell the person saving what the save started, such as sending a deal for approval. The single-record write answer carried `warnings` (advisory validation-rule hits) and nothing else a save could say. Now a hook can attach one message to the save's answer.
14+
15+
- **Spec.** `CreateDataResponseSchema` and `UpdateDataResponseSchema` declare an optional, response-only `notice`: one author-written message, info severity, separate from `warnings`. When more than one call sets it, the last one wins. The key is omitted when no hook set one, and it is never stored on the record. `WriteObservabilityOptions` declares the in-process listener a hook calls to set it, `onNotice(message)`, beside `onFieldsDropped`, `onValidationAdvisory` and `onMentionsWithheld`. Like them it never crosses the wire.
16+
- **metadata-protocol.** `createData` and `updateData` pass the listener and answer the last non-empty message as `notice`. A nested write that a hook or a flow makes inside this one carries its own options, so its notice never reaches this answer.
17+
- **client.** `CreateDataResult` and `UpdateDataResult` declare `notice?: string`, mirroring the two schemas.
18+
- **objectql.** `engine.update` accepts the `onNotice` option. The engine never calls it; it carries it to the hooks with the rest of the caller's bag.
19+
20+
How a hook sets it: read the write's own options bag off `ctx.input.options` and call the listener when it is there.
21+
22+
```ts
23+
const opts = ctx.input.options as WriteObservabilityOptions | undefined;
24+
opts?.onNotice?.('Sent to your manager for approval');
25+
```
26+
27+
A hook whose handler runs as code in the server process (registered with `registerHook`, or a declarative `handler` bound as a function) can call it today. A hook that runs in the sandbox (a `body`, including a `handler` the build lowers into one) cannot yet, and neither can a record-triggered flow: both are follow-up work.
28+
29+
Nothing to do for existing clients: a client that does not read `notice` sees the same answer as before.

‎content/docs/api/data-api.mdx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,13 @@ the requester's own answer only. The comment is saved
239239
with every mention, and nothing about the withheld users is stored on it. With
240240
nobody withheld, the key is absent.
241241

242+
When a hook on the write tells the person who saved what the save set in motion
243+
("Sent to your manager for approval"), the answer also carries `notice`: that one
244+
author-written message, info severity, separate from `warnings`. Show it with, or
245+
in place of, your own success message. If more than one hook sets it, the last one
246+
wins. A hook on a nested write never reaches it, nothing about it is stored, and
247+
with no notice set the key is absent.
248+
242249
### `PATCH /data/:object/:id`
243250

244251
Update an existing record (partial update).
@@ -249,6 +256,8 @@ Update an existing record (partial update).
249256
A `sys_comment` update answers `withheldMentions` the same way, for the mentions
250257
the update added. A mention the comment already carried is not judged again.
251258

259+
It answers `notice` the same way as a create, when a hook on the update set one.
260+
252261
### `DELETE /data/:object/:id`
253262

254263
Delete a record.

‎content/docs/kernel/contracts/data-engine.mdx‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -327,13 +327,15 @@ interface EngineUpdateOptions {
327327

328328
### WriteObservabilityOptions
329329

330-
The write methods (`insert` / `update`) additionally accept four **in-process**
330+
The write methods (`insert` / `update`) additionally accept five **in-process**
331331
options. Two govern what happens when caller-supplied write fields are legally
332332
stripped from the payload before the driver write: observe the strip
333333
(`onFieldsDropped`) or refuse the write outright (`strictReadonlyWrites`). The
334334
third, `onValidationAdvisory`, reports the write's advisory validation-rule hits.
335335
The fourth, `onMentionsWithheld`, reports the users a comment write @mentioned who
336336
will not be notified.
337+
The fifth, `onNotice`, is how a hook tells the person who made the write what
338+
the save set in motion.
337339

338340
The strips these two options cover are the engine's legal ones:
339341

@@ -360,6 +362,7 @@ interface WriteObservabilityOptions {
360362
onFieldsDropped?: (event: DroppedFieldsEvent) => void;
361363
onValidationAdvisory?: (event: ValidationAdvisoryEvent) => void;
362364
onMentionsWithheld?: (userIds: readonly string[]) => void;
365+
onNotice?: (message: string) => void;
363366
strictReadonlyWrites?: boolean; // refuse instead of stripping. Default: false
364367
}
365368

@@ -475,6 +478,40 @@ ids as `withheldMentions`, on the requester's own answer to that request only an
475478
least one user was withheld. Nothing is stored on the comment: a stored member
476479
would tell every reader of the comment who cannot read its thread.
477480

481+
#### `onNotice` — what the save set in motion
482+
483+
A hook that starts something the person saving should hear about ("Sent to your
484+
manager for approval") calls this listener with one author-written message. It is
485+
info severity: it reports an outcome, never a problem, so it is not a validation
486+
finding and never rides `onValidationAdvisory`. The engine never calls it. A hook
487+
reaches it the way every member of this bag reaches a hook, on
488+
`ctx.input.options`: a `before*` handler reads the caller's bag as passed, an
489+
`after*` handler after `buildDriverOptions` widened it.
490+
491+
{/* os:check */}
492+
```typescript
493+
import type { HookContext } from '@objectstack/spec/data';
494+
import type { WriteObservabilityOptions } from '@objectstack/spec/contracts';
495+
496+
export async function announceApproval(ctx: HookContext): Promise<void> {
497+
const opts = ctx.input.options as WriteObservabilityOptions | undefined;
498+
opts?.onNotice?.('Sent to your manager for approval');
499+
}
500+
```
501+
502+
The listener is often absent (a seed, a flow node, a batch or an import passes
503+
none), so call it optionally. One message per write: a later call replaces an
504+
earlier one, and a message that is not a non-empty string sets nothing. A write
505+
that is refused answers its error, never a notice. A nested write that a hook or a
506+
flow makes inside this one carries its own options, so its notice never reaches
507+
this listener. The sandboxed `body` face (including a `handler` the build lowers
508+
into a body) does not carry it: its `ctx.input.options` is the frozen
509+
`{ multi, where }` projection.
510+
511+
The DataProtocol's `createData` and `updateData` pass this listener and answer
512+
the message as `notice`, on the requester's own answer to that request only and
513+
present only when a hook set one. Nothing is stored on the record.
514+
478515
<Callout type="warn">
479516
**Layering — this is the engine seam, and since the 2026-09-03 ruling the only
480517
one.** The DataProtocol create faces (`createData`, `cloneData`, `createManyData`,
@@ -486,7 +523,7 @@ declares one; none carries a `readonly` policy of its own any more — see
486523

487524
<Callout type="warn">
488525
`WriteObservabilityOptions` is a **TS-contract-level, in-process-only** bag —
489-
all four members. It is deliberately not part of the serializable Zod options schemas:
526+
all five members. It is deliberately not part of the serializable Zod options schemas:
490527
a function is unrepresentable in JSON Schema and cannot be serialized, so remote
491528
callers (REST, the client SDK) never receive these events; and putting
492529
`strictReadonlyWrites` in the serializable bag would let any client toggle

‎content/docs/references/api/protocol.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa
557557
| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Write-observability: caller-supplied fields that were LEGALLY stripped before the record was written — a non-system create cannot seed a static `readonly` column (the strip runs inside `engine.insert`, after the `beforeInsert` hooks, `isSystem`-gated), so those keys are dropped and the field re-derives its default. Present ONLY when ≥1 field was dropped; the create still succeeded without them (status/success semantics unchanged). REST additionally surfaces this as the `X-ObjectStack-Dropped-Fields` response header. Optional — omit-when-empty keeps the shape backward-compatible for existing clients. |
558558
| **warnings** | `{ field: string; code: string; message: string; rule?: string; … }[]` | optional | The advisory validation-rule hits of THIS write: each `severity: 'warning'` or `'info'` rule whose verdict on the written record was "violated", with its `rule` name, `severity`, `field`, `code` and author-written `message` — the same element the validate-only preview reports in a row's `warnings`. Advisory rules never block, so the write succeeded; this is the advisory to show the person who made it. Present ONLY when at least one advisory rule hit; a write with no hit omits the key. Hits of a nested write a hook or a flow made inside this one are not included, and a rule that could not be evaluated is not a hit. |
559559
| **withheldMentions** | `string[]` | optional | The ids of the users this write @mentioned whose mention notification was WITHHELD by the mention recipient gate, so they will not be notified: the gate's own verdict on THIS write, ids only, never the reason. Only a `sys_comment` write fills it, and only on the requester's own answer to the request that made the write; on an update, only a mention the update added is judged. The comment was saved with every mention; the requester is told here that the named users will not be notified. Response-only: never stored on the comment (a stored member would tell every reader who cannot read the thread). Present ONLY when at least one mention was withheld; otherwise the key is omitted. |
560+
| **notice** | `string` | optional | One author-written message telling the person who made THIS write what the save set in motion (for example "Sent to your manager for approval"), set by a hook running on the write through the write's own `onNotice` listener (`WriteObservabilityOptions`). Info severity: it reports an outcome, never a problem, so it is distinct from `warnings` (the advisory validation-rule hits) and a client shows it with, or in place of, its own success message. One message: when more than one call sets it, the LAST one wins. Present ONLY when a hook set a non-empty message on a write that succeeded; otherwise the key is omitted. A nested write a hook or a flow made inside this one carries its own options, so its notice never reaches this answer. Response-only: never stored on the record. |
560561

561562
### Nested Shape: `CreateDataResponse.droppedFields[number]`
562563

@@ -3043,6 +3044,7 @@ Uninstall package response
30433044
| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Write-observability: caller-supplied fields the engine LEGALLY stripped from the write before persisting — static `readonly` or a TRUE `readonlyWhen` predicate. Present ONLY when ≥1 field was dropped; the update still succeeded without them (status/success semantics unchanged — stripping is legitimate, not an error). REST additionally surfaces this as the `X-ObjectStack-Dropped-Fields` response header. Optional — omit-when-empty keeps the shape backward-compatible for existing clients that only read `record`. |
30443045
| **warnings** | `{ field: string; code: string; message: string; rule?: string; … }[]` | optional | The advisory validation-rule hits of THIS write: each `severity: 'warning'` or `'info'` rule whose verdict on the written record was "violated", with its `rule` name, `severity`, `field`, `code` and author-written `message` — the same element the validate-only preview reports in a row's `warnings`. Advisory rules never block, so the write succeeded; this is the advisory to show the person who made it. Present ONLY when at least one advisory rule hit; a write with no hit omits the key. Hits of a nested write a hook or a flow made inside this one are not included, and a rule that could not be evaluated is not a hit. |
30453046
| **withheldMentions** | `string[]` | optional | The ids of the users this write @mentioned whose mention notification was WITHHELD by the mention recipient gate, so they will not be notified: the gate's own verdict on THIS write, ids only, never the reason. Only a `sys_comment` write fills it, and only on the requester's own answer to the request that made the write; on an update, only a mention the update added is judged. The comment was saved with every mention; the requester is told here that the named users will not be notified. Response-only: never stored on the comment (a stored member would tell every reader who cannot read the thread). Present ONLY when at least one mention was withheld; otherwise the key is omitted. |
3047+
| **notice** | `string` | optional | One author-written message telling the person who made THIS write what the save set in motion (for example "Sent to your manager for approval"), set by a hook running on the write through the write's own `onNotice` listener (`WriteObservabilityOptions`). Info severity: it reports an outcome, never a problem, so it is distinct from `warnings` (the advisory validation-rule hits) and a client shows it with, or in place of, its own success message. One message: when more than one call sets it, the LAST one wins. Present ONLY when a hook set a non-empty message on a write that succeeded; otherwise the key is omitted. A nested write a hook or a flow made inside this one carries its own options, so its notice never reaches this answer. Response-only: never stored on the record. |
30463048

30473049
### Nested Shape: `UpdateDataResponse.droppedFields[number]`
30483050

‎packages/client/src/index.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,14 @@ export interface CreateDataResult<T = any> {
498498
* comment. Present only when at least one mention was withheld.
499499
*/
500500
withheldMentions?: string[];
501+
/**
502+
* [#28000] One author-written message a hook on this write set to tell the
503+
* person who made it what the save set in motion (for example "Sent to your
504+
* manager for approval"). Info severity, distinct from `warnings`: show it
505+
* with, or in place of, your own success message. Present only when a hook
506+
* set one; never stored on the record.
507+
*/
508+
notice?: string;
501509
}
502510

503511
/**
@@ -560,6 +568,14 @@ export interface UpdateDataResult<T = any> {
560568
* comment. Present only when at least one mention was withheld.
561569
*/
562570
withheldMentions?: string[];
571+
/**
572+
* [#28000] One author-written message a hook on this write set to tell the
573+
* person who made it what the save set in motion (for example "Sent to your
574+
* manager for approval"). Info severity, distinct from `warnings`: show it
575+
* with, or in place of, your own success message. Present only when a hook
576+
* set one; never stored on the record.
577+
*/
578+
notice?: string;
563579
}
564580

565581
/**

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13743,10 +13743,17 @@ export class ObjectStackProtocolImplementation implements
1374313743
// nor any other request's answer ever carries them, and nothing is
1374413744
// stored on the comment.
1374513745
const withheldMentions = new Set<string>();
13746+
// [#28000] The one message a hook on THIS write set to tell the
13747+
// requester what the save set in motion, answered as `notice`. The hook
13748+
// reads the listener off `ctx.input.options`, which is this bag, so a
13749+
// nested write's hook never reaches it; a later call replaces an
13750+
// earlier one, and anything but a non-empty string sets nothing.
13751+
let notice: string | undefined;
1374613752
const opts: any = {
1374713753
onFieldsDropped: (e: DroppedFieldsEvent) => { dropped.push(e); },
1374813754
onValidationAdvisory: (e: ValidationAdvisoryEvent) => { warnings.push(e); },
1374913755
onMentionsWithheld: (ids: readonly string[]) => { for (const id of ids) withheldMentions.add(id); },
13756+
onNotice: (message: unknown) => { if (typeof message === 'string' && message.trim() !== '') notice = message; },
1375013757
};
1375113758
if (request.context !== undefined) opts.context = request.context;
1375213759
const result = await this.engine.insert(request.object, request.data, opts);
@@ -13765,6 +13772,7 @@ export class ObjectStackProtocolImplementation implements
1376513772
...(dropped.length > 0 ? { droppedFields: dropped } : {}),
1376613773
...(warnings.length > 0 ? { warnings } : {}),
1376713774
...(withheldMentions.size > 0 ? { withheldMentions: [...withheldMentions] } : {}),
13775+
...(notice !== undefined ? { notice } : {}),
1376813776
};
1376913777
}
1377013778

@@ -13917,6 +13925,9 @@ export class ObjectStackProtocolImplementation implements
1391713925
// answered as `withheldMentions` (see `createData`).
1391813926
const withheldMentions = new Set<string>();
1391913927
opts.onMentionsWithheld = (ids: readonly string[]) => { for (const id of ids) withheldMentions.add(id); };
13928+
// [#28000] The hook-set message answered as `notice` (see `createData`).
13929+
let notice: string | undefined;
13930+
opts.onNotice = (message: unknown) => { if (typeof message === 'string' && message.trim() !== '') notice = message; };
1392013931
// [#6479] At THIS ingress the row is the one the caller named — `request.id`,
1392113932
// the path `:id` — and nothing in the payload gets to move it.
1392213933
//
@@ -13994,6 +14005,7 @@ export class ObjectStackProtocolImplementation implements
1399414005
...(dropped.length > 0 ? { droppedFields: dropped } : {}),
1399514006
...(warnings.length > 0 ? { warnings } : {}),
1399614007
...(withheldMentions.size > 0 ? { withheldMentions: [...withheldMentions] } : {}),
14008+
...(notice !== undefined ? { notice } : {}),
1399714009
};
1399814010
}
1399914011

‎packages/objectql/src/engine-unknown-option.test.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,10 @@ describe('unknown engine option keys are rejected (#4371 option 2)', () => {
200200
await engine.update('task', { title: 'A5' }, { where: { id: a.id }, onMentionsWithheld: () => {} } as any);
201201
});
202202

203+
it('onNotice passes on update (contract-declared write observability, #28000)', async () => {
204+
await engine.update('task', { title: 'A6' }, { where: { id: a.id }, onNotice: () => {} } as any);
205+
});
206+
203207
it('searchFields passes on find (read by the $search expansion)', async () => {
204208
// Legality pin only — the $or/$contains expansion itself is covered by
205209
// the search-filter tests; this mock driver does not evaluate it.
@@ -263,9 +267,10 @@ describe('unknown engine option keys are rejected (#4371 option 2)', () => {
263267
// this list must. Spelling it here is what keeps the engine from
264268
// rejecting a key the TS contract declares. [#22726]
265269
// `onValidationAdvisory` is the third such member.
266-
// `onMentionsWithheld` (#27703) is the fourth: the engine only carries
267-
// it to the `after*` hooks, on `ctx.input.options`.
268-
expectSetMatches('update', EngineUpdateOptionsSchema as any, ['onFieldsDropped', 'onValidationAdvisory', 'onMentionsWithheld', 'strictReadonlyWrites', ...PASSTHROUGH]);
270+
// `onMentionsWithheld` (#27703) is the fourth and `onNotice` (#28000)
271+
// the fifth: the engine only carries them to the hooks, on
272+
// `ctx.input.options`.
273+
expectSetMatches('update', EngineUpdateOptionsSchema as any, ['onFieldsDropped', 'onValidationAdvisory', 'onMentionsWithheld', 'onNotice', 'strictReadonlyWrites', ...PASSTHROUGH]);
269274
expectSetMatches('delete', EngineDeleteOptionsSchema as any, PASSTHROUGH);
270275
expectSetMatches('count', EngineCountOptionsSchema as any, []);
271276
expectSetMatches('aggregate', EngineAggregateOptionsSchema as any, []);

‎packages/objectql/src/engine.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -716,7 +716,7 @@ const ENGINE_FIND_OPTION_KEYS: ReadonlySet<string> = new Set([
716716
...ENGINE_DRIVER_PASSTHROUGH_KEYS,
717717
]);
718718
const ENGINE_UPDATE_OPTION_KEYS: ReadonlySet<string> = new Set([
719-
'context', 'where', 'multi', 'returning', 'onFieldsDropped', 'onValidationAdvisory', 'onMentionsWithheld', 'strictReadonlyWrites',
719+
'context', 'where', 'multi', 'returning', 'onFieldsDropped', 'onValidationAdvisory', 'onMentionsWithheld', 'onNotice', 'strictReadonlyWrites',
720720
...ENGINE_DRIVER_PASSTHROUGH_KEYS,
721721
]);
722722
const ENGINE_DELETE_OPTION_KEYS: ReadonlySet<string> = new Set([

0 commit comments

Comments
 (0)