diff --git a/.changeset/28000-save-notice-answer.md b/.changeset/28000-save-notice-answer.md new file mode 100644 index 00000000000..ba2b8cbcd19 --- /dev/null +++ b/.changeset/28000-save-notice-answer.md @@ -0,0 +1,29 @@ +--- +'@objectstack/spec': minor +'@objectstack/metadata-protocol': minor +'@objectstack/objectql': minor +'@objectstack/client': minor +--- + +feat(spec): a hook can tell the person who saved a record what the save set in motion + +Clause-②: yes +Metadata-protocol: added + +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. + +- **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. +- **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. +- **client.** `CreateDataResult` and `UpdateDataResult` declare `notice?: string`, mirroring the two schemas. +- **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. + +How a hook sets it: read the write's own options bag off `ctx.input.options` and call the listener when it is there. + +```ts +const opts = ctx.input.options as WriteObservabilityOptions | undefined; +opts?.onNotice?.('Sent to your manager for approval'); +``` + +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. + +Nothing to do for existing clients: a client that does not read `notice` sees the same answer as before. diff --git a/content/docs/api/data-api.mdx b/content/docs/api/data-api.mdx index 2583e760c3c..973200b4e44 100644 --- a/content/docs/api/data-api.mdx +++ b/content/docs/api/data-api.mdx @@ -239,6 +239,13 @@ the requester's own answer only. The comment is saved with every mention, and nothing about the withheld users is stored on it. With nobody withheld, the key is absent. +When a hook on the write tells the person who saved what the save set in motion +("Sent to your manager for approval"), the answer also carries `notice`: that one +author-written message, info severity, separate from `warnings`. Show it with, or +in place of, your own success message. If more than one hook sets it, the last one +wins. A hook on a nested write never reaches it, nothing about it is stored, and +with no notice set the key is absent. + ### `PATCH /data/:object/:id` Update an existing record (partial update). @@ -249,6 +256,8 @@ Update an existing record (partial update). A `sys_comment` update answers `withheldMentions` the same way, for the mentions the update added. A mention the comment already carried is not judged again. +It answers `notice` the same way as a create, when a hook on the update set one. + ### `DELETE /data/:object/:id` Delete a record. diff --git a/content/docs/kernel/contracts/data-engine.mdx b/content/docs/kernel/contracts/data-engine.mdx index 4662915e927..e42457553e3 100644 --- a/content/docs/kernel/contracts/data-engine.mdx +++ b/content/docs/kernel/contracts/data-engine.mdx @@ -327,13 +327,15 @@ interface EngineUpdateOptions { ### WriteObservabilityOptions -The write methods (`insert` / `update`) additionally accept four **in-process** +The write methods (`insert` / `update`) additionally accept five **in-process** options. Two govern what happens when caller-supplied write fields are legally stripped from the payload before the driver write: observe the strip (`onFieldsDropped`) or refuse the write outright (`strictReadonlyWrites`). The third, `onValidationAdvisory`, reports the write's advisory validation-rule hits. The fourth, `onMentionsWithheld`, reports the users a comment write @mentioned who will not be notified. +The fifth, `onNotice`, is how a hook tells the person who made the write what +the save set in motion. The strips these two options cover are the engine's legal ones: @@ -360,6 +362,7 @@ interface WriteObservabilityOptions { onFieldsDropped?: (event: DroppedFieldsEvent) => void; onValidationAdvisory?: (event: ValidationAdvisoryEvent) => void; onMentionsWithheld?: (userIds: readonly string[]) => void; + onNotice?: (message: string) => void; strictReadonlyWrites?: boolean; // refuse instead of stripping. Default: false } @@ -475,6 +478,40 @@ ids as `withheldMentions`, on the requester's own answer to that request only an least one user was withheld. Nothing is stored on the comment: a stored member would tell every reader of the comment who cannot read its thread. +#### `onNotice` — what the save set in motion + +A hook that starts something the person saving should hear about ("Sent to your +manager for approval") calls this listener with one author-written message. It is +info severity: it reports an outcome, never a problem, so it is not a validation +finding and never rides `onValidationAdvisory`. The engine never calls it. A hook +reaches it the way every member of this bag reaches a hook, on +`ctx.input.options`: a `before*` handler reads the caller's bag as passed, an +`after*` handler after `buildDriverOptions` widened it. + +{/* os:check */} +```typescript +import type { HookContext } from '@objectstack/spec/data'; +import type { WriteObservabilityOptions } from '@objectstack/spec/contracts'; + +export async function announceApproval(ctx: HookContext): Promise { + const opts = ctx.input.options as WriteObservabilityOptions | undefined; + opts?.onNotice?.('Sent to your manager for approval'); +} +``` + +The listener is often absent (a seed, a flow node, a batch or an import passes +none), so call it optionally. One message per write: a later call replaces an +earlier one, and a message that is not a non-empty string sets nothing. A write +that is refused answers its error, never a notice. A nested write that a hook or a +flow makes inside this one carries its own options, so its notice never reaches +this listener. The sandboxed `body` face (including a `handler` the build lowers +into a body) does not carry it: its `ctx.input.options` is the frozen +`{ multi, where }` projection. + +The DataProtocol's `createData` and `updateData` pass this listener and answer +the message as `notice`, on the requester's own answer to that request only and +present only when a hook set one. Nothing is stored on the record. + **Layering — this is the engine seam, and since the 2026-09-03 ruling the only 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 `WriteObservabilityOptions` is a **TS-contract-level, in-process-only** bag — -all four members. It is deliberately not part of the serializable Zod options schemas: +all five members. It is deliberately not part of the serializable Zod options schemas: a function is unrepresentable in JSON Schema and cannot be serialized, so remote callers (REST, the client SDK) never receive these events; and putting `strictReadonlyWrites` in the serializable bag would let any client toggle diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index cd7b52779a7..3c6ed44e69f 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -557,6 +557,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | **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. | | **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. | | **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. | +| **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. | ### Nested Shape: `CreateDataResponse.droppedFields[number]` @@ -3043,6 +3044,7 @@ Uninstall package response | **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`. | | **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. | | **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. | +| **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. | ### Nested Shape: `UpdateDataResponse.droppedFields[number]` diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 27764d8fbf1..4305d991aa2 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -498,6 +498,14 @@ export interface CreateDataResult { * comment. Present only when at least one mention was withheld. */ withheldMentions?: string[]; + /** + * [#28000] One author-written message a hook on this write set to tell the + * person who made it what the save set in motion (for example "Sent to your + * manager for approval"). Info severity, distinct from `warnings`: show it + * with, or in place of, your own success message. Present only when a hook + * set one; never stored on the record. + */ + notice?: string; } /** @@ -560,6 +568,14 @@ export interface UpdateDataResult { * comment. Present only when at least one mention was withheld. */ withheldMentions?: string[]; + /** + * [#28000] One author-written message a hook on this write set to tell the + * person who made it what the save set in motion (for example "Sent to your + * manager for approval"). Info severity, distinct from `warnings`: show it + * with, or in place of, your own success message. Present only when a hook + * set one; never stored on the record. + */ + notice?: string; } /** diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 9d7d93ef9a3..a272593a710 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -13743,10 +13743,17 @@ export class ObjectStackProtocolImplementation implements // nor any other request's answer ever carries them, and nothing is // stored on the comment. const withheldMentions = new Set(); + // [#28000] The one message a hook on THIS write set to tell the + // requester what the save set in motion, answered as `notice`. The hook + // reads the listener off `ctx.input.options`, which is this bag, so a + // nested write's hook never reaches it; a later call replaces an + // earlier one, and anything but a non-empty string sets nothing. + let notice: string | undefined; const opts: any = { onFieldsDropped: (e: DroppedFieldsEvent) => { dropped.push(e); }, onValidationAdvisory: (e: ValidationAdvisoryEvent) => { warnings.push(e); }, onMentionsWithheld: (ids: readonly string[]) => { for (const id of ids) withheldMentions.add(id); }, + onNotice: (message: unknown) => { if (typeof message === 'string' && message.trim() !== '') notice = message; }, }; if (request.context !== undefined) opts.context = request.context; const result = await this.engine.insert(request.object, request.data, opts); @@ -13765,6 +13772,7 @@ export class ObjectStackProtocolImplementation implements ...(dropped.length > 0 ? { droppedFields: dropped } : {}), ...(warnings.length > 0 ? { warnings } : {}), ...(withheldMentions.size > 0 ? { withheldMentions: [...withheldMentions] } : {}), + ...(notice !== undefined ? { notice } : {}), }; } @@ -13917,6 +13925,9 @@ export class ObjectStackProtocolImplementation implements // answered as `withheldMentions` (see `createData`). const withheldMentions = new Set(); opts.onMentionsWithheld = (ids: readonly string[]) => { for (const id of ids) withheldMentions.add(id); }; + // [#28000] The hook-set message answered as `notice` (see `createData`). + let notice: string | undefined; + opts.onNotice = (message: unknown) => { if (typeof message === 'string' && message.trim() !== '') notice = message; }; // [#6479] At THIS ingress the row is the one the caller named — `request.id`, // the path `:id` — and nothing in the payload gets to move it. // @@ -13994,6 +14005,7 @@ export class ObjectStackProtocolImplementation implements ...(dropped.length > 0 ? { droppedFields: dropped } : {}), ...(warnings.length > 0 ? { warnings } : {}), ...(withheldMentions.size > 0 ? { withheldMentions: [...withheldMentions] } : {}), + ...(notice !== undefined ? { notice } : {}), }; } diff --git a/packages/objectql/src/engine-unknown-option.test.ts b/packages/objectql/src/engine-unknown-option.test.ts index 90eed3d412f..9b58febf8ec 100644 --- a/packages/objectql/src/engine-unknown-option.test.ts +++ b/packages/objectql/src/engine-unknown-option.test.ts @@ -200,6 +200,10 @@ describe('unknown engine option keys are rejected (#4371 option 2)', () => { await engine.update('task', { title: 'A5' }, { where: { id: a.id }, onMentionsWithheld: () => {} } as any); }); + it('onNotice passes on update (contract-declared write observability, #28000)', async () => { + await engine.update('task', { title: 'A6' }, { where: { id: a.id }, onNotice: () => {} } as any); + }); + it('searchFields passes on find (read by the $search expansion)', async () => { // Legality pin only — the $or/$contains expansion itself is covered by // 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)', () => { // this list must. Spelling it here is what keeps the engine from // rejecting a key the TS contract declares. [#22726] // `onValidationAdvisory` is the third such member. - // `onMentionsWithheld` (#27703) is the fourth: the engine only carries - // it to the `after*` hooks, on `ctx.input.options`. - expectSetMatches('update', EngineUpdateOptionsSchema as any, ['onFieldsDropped', 'onValidationAdvisory', 'onMentionsWithheld', 'strictReadonlyWrites', ...PASSTHROUGH]); + // `onMentionsWithheld` (#27703) is the fourth and `onNotice` (#28000) + // the fifth: the engine only carries them to the hooks, on + // `ctx.input.options`. + expectSetMatches('update', EngineUpdateOptionsSchema as any, ['onFieldsDropped', 'onValidationAdvisory', 'onMentionsWithheld', 'onNotice', 'strictReadonlyWrites', ...PASSTHROUGH]); expectSetMatches('delete', EngineDeleteOptionsSchema as any, PASSTHROUGH); expectSetMatches('count', EngineCountOptionsSchema as any, []); expectSetMatches('aggregate', EngineAggregateOptionsSchema as any, []); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index cf47a65225b..34f177e5fa9 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -716,7 +716,7 @@ const ENGINE_FIND_OPTION_KEYS: ReadonlySet = new Set([ ...ENGINE_DRIVER_PASSTHROUGH_KEYS, ]); const ENGINE_UPDATE_OPTION_KEYS: ReadonlySet = new Set([ - 'context', 'where', 'multi', 'returning', 'onFieldsDropped', 'onValidationAdvisory', 'onMentionsWithheld', 'strictReadonlyWrites', + 'context', 'where', 'multi', 'returning', 'onFieldsDropped', 'onValidationAdvisory', 'onMentionsWithheld', 'onNotice', 'strictReadonlyWrites', ...ENGINE_DRIVER_PASSTHROUGH_KEYS, ]); const ENGINE_DELETE_OPTION_KEYS: ReadonlySet = new Set([ diff --git a/packages/objectql/src/protocol-save-notice-answer.test.ts b/packages/objectql/src/protocol-save-notice-answer.test.ts new file mode 100644 index 00000000000..33e15a864d5 --- /dev/null +++ b/packages/objectql/src/protocol-save-notice-answer.test.ts @@ -0,0 +1,245 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28000] A hook tells the person who saved a record what the save set in + * motion, and the message reaches THAT person's write answer as `notice`. + * + * The transport, as built: + * + * createData / updateData → pass THIS write's `onNotice` listener + * (`WriteObservabilityOptions`) in the engine bag + * engine (insert / update) → never calls it; hands the caller's bag to every + * hook on `ctx.input.options` (a `before*` hook reads + * it as passed, an `after*` hook after + * `buildDriverOptions` widened it, never stripped) + * a hook → calls it with one message + * createData / updateData → answer the last non-empty message as `notice`, + * omit-when-absent + * + * A REAL `ObjectQL` engine and the REAL `ObjectStackProtocolImplementation` run + * below, on both hook surfaces an app reaches the bag from: the raw + * `registerHook` envelope and a declarative `handler` bound through + * `bindHooksToEngine` (whose flat record view passes `options` through to the + * envelope). The hooks say what the written row asks them to: `say` from the + * `before*` phase, `say_after` from the `after*` phase. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { CreateDataResponseSchema, UpdateDataResponseSchema } from '@objectstack/spec/api'; +import type { WriteObservabilityOptions } from '@objectstack/spec/contracts'; +import { ObjectQL } from './engine.js'; +import { bindHooksToEngine } from './hook-binder.js'; + +const DEAL = { + name: 'sn_deal', + label: 'Deal', + fields: { + body: { name: 'body', label: 'Body', type: 'text' }, + say: { name: 'say', label: 'Say', type: 'text' }, + say_after: { name: 'say_after', label: 'Say after', type: 'text' }, + }, +}; + +function makeMemoryDriver() { + const stores = new Map>>(); + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + let nextId = 0; + const matches = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + return Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) return true; + const expected = v && typeof v === 'object' && '$eq' in (v as any) ? (v as any).$eq : v; + return (row[k] ?? null) === (expected ?? null); + }); + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + const rows = [...storeFor(object).values()].filter((r) => matches(r, ast?.where)); + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const row = { ...data, id: (data.id as string) ?? `r_${nextId}` }; + storeFor(object).set(row.id as string, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const row = { ...storeFor(object).get(id), ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, storeFor }; +} + +const noopLogger = { info() {}, debug() {}, warn() {}, error() {} }; + +let engine: ObjectQL; +let protocol: ObjectStackProtocolImplementation; +let storeFor: ReturnType['storeFor']; +/** Set by a test: the after-hook writes a nested deal asking for this notice inside the outer write. */ +let nestedSay: string | undefined; + +const listenerOf = (ctx: any) => (ctx.input?.options as WriteObservabilityOptions | undefined)?.onNotice; + +/** Raw-envelope hooks: `ctx.input.data` is the payload, `ctx.input.options` the caller's bag. */ +const beforeSay = async (ctx: any) => { + const say = ctx.input?.data?.say; + if (say !== undefined) listenerOf(ctx)?.(say); + if (ctx.input?.data?.body === 'refuse') throw new Error('refused by a before hook'); +}; +const afterSay = async (ctx: any) => { + const say = ctx.result?.say_after; + if (say !== undefined) listenerOf(ctx)?.(say); + if (nestedSay !== undefined && ctx.event === 'afterInsert') { + const nested = nestedSay; + nestedSay = undefined; + await engine.insert(DEAL.name, { body: 'nested', say: nested }); + } +}; + +async function boot(surface: 'envelope' | 'declarative') { + engine = new ObjectQL({ logger: noopLogger } as any); + const d = makeMemoryDriver(); + storeFor = d.storeFor; + engine.registerDriver(d.driver, true); + await engine.init(); + engine.registry.registerObject(DEAL as never, 'com.objectstack.test.28000'); + if (surface === 'envelope') { + for (const e of ['beforeInsert', 'beforeUpdate'] as const) engine.registerHook(e, beforeSay, { object: DEAL.name }); + for (const e of ['afterInsert', 'afterUpdate'] as const) engine.registerHook(e, afterSay, { object: DEAL.name }); + } else { + // A declarative `handler` sees the flat record view: `input.say` is the + // field, and `input.options` passes through to the envelope's bag. + const bound = bindHooksToEngine(engine, [ + { + name: 'sn_deal_before', object: DEAL.name, events: ['beforeInsert', 'beforeUpdate'], + handler: async (ctx: any) => { if (ctx.input.say !== undefined) listenerOf(ctx)?.(ctx.input.say); }, + }, + { + name: 'sn_deal_after', object: DEAL.name, events: ['afterInsert', 'afterUpdate'], + handler: async (ctx: any) => { if (ctx.result?.say_after !== undefined) listenerOf(ctx)?.(ctx.result.say_after); }, + }, + ] as never, { logger: noopLogger as never }); + expect(bound.errors).toEqual([]); + expect(bound.registered).toBe(4); // one registration per (hook, event) + } + protocol = new ObjectStackProtocolImplementation(engine as never); + nestedSay = undefined; +} + +describe('[#28000] createData answers a hook-set message as `notice`', () => { + beforeEach(() => boot('envelope')); + + it('answers the message a beforeInsert hook set, and the answer conforms', async () => { + const answer: any = await protocol.createData({ object: DEAL.name, data: { body: 'hi', say: 'Sent for approval' } }); + expect(answer.notice).toBe('Sent for approval'); + expect(CreateDataResponseSchema.safeParse(answer).success).toBe(true); + }); + + it('answers the message an afterInsert hook set', async () => { + const answer: any = await protocol.createData({ object: DEAL.name, data: { body: 'hi', say_after: 'Renewal created' } }); + expect(answer.notice).toBe('Renewal created'); + }); + + it('control: no hook set one → no `notice` key at all', async () => { + const answer: any = await protocol.createData({ object: DEAL.name, data: { body: 'hi' } }); + expect('notice' in answer).toBe(false); + }); + + it('one message: the last call wins', async () => { + const answer: any = await protocol.createData({ + object: DEAL.name, data: { body: 'hi', say: 'first', say_after: 'second' }, + }); + expect(answer.notice).toBe('second'); + }); + + it('a blank message sets nothing, and never clears an earlier one', async () => { + const blank: any = await protocol.createData({ object: DEAL.name, data: { body: 'hi', say: ' ' } }); + expect('notice' in blank).toBe(false); + const kept: any = await protocol.createData({ object: DEAL.name, data: { body: 'hi', say: 'kept', say_after: '' } }); + expect(kept.notice).toBe('kept'); + }); + + it('a refused write answers its error, never the notice a before hook set first', async () => { + await expect(protocol.createData({ object: DEAL.name, data: { body: 'refuse', say: 'Sent for approval' } })) + .rejects.toThrow(/refused by a before hook/); + }); + + it('nothing is stored: the persisted row and the answered record carry no `notice`', async () => { + const answer: any = await protocol.createData({ object: DEAL.name, data: { body: 'hi', say: 'Sent for approval' } }); + expect(Object.keys(storeFor(DEAL.name).get(answer.id)!)).not.toContain('notice'); + expect('notice' in answer.record).toBe(false); + }); + + it('a nested write a hook makes inside this one adds nothing to the outer answer, though its hook DID call', async () => { + nestedSay = 'nested notice'; + const answer: any = await protocol.createData({ object: DEAL.name, data: { body: 'outer' } }); + expect(nestedSay).toBeUndefined(); // the nested deal was written, asking for a notice … + expect([...storeFor(DEAL.name).values()].some((r) => r.say === 'nested notice')).toBe(true); + expect('notice' in answer).toBe(false); // … and the outer answer was told nothing. + }); + + it('each request answers only its own: a second create is not told the first one\'s notice', async () => { + const first: any = await protocol.createData({ object: DEAL.name, data: { body: 'a', say: 'Sent for approval' } }); + const second: any = await protocol.createData({ object: DEAL.name, data: { body: 'b' } }); + expect(first.notice).toBe('Sent for approval'); + expect('notice' in second).toBe(false); + }); +}); + +describe('[#28000] updateData answers a hook-set message as `notice`', () => { + beforeEach(() => boot('envelope')); + + it('answers the message a beforeUpdate hook set, and the answer conforms', async () => { + const created: any = await protocol.createData({ object: DEAL.name, data: { body: 'hi' } }); + const answer: any = await protocol.updateData({ object: DEAL.name, id: created.id, data: { say: 'Sent for approval' } }); + expect(answer.notice).toBe('Sent for approval'); + expect(UpdateDataResponseSchema.safeParse(answer).success).toBe(true); + }); + + it('answers the message an afterUpdate hook set, the last call winning', async () => { + const created: any = await protocol.createData({ object: DEAL.name, data: { body: 'hi' } }); + const answer: any = await protocol.updateData({ + object: DEAL.name, id: created.id, data: { say: 'first', say_after: 'Approval requested' }, + }); + expect(answer.notice).toBe('Approval requested'); + }); + + it('control: no hook set one → no `notice` key at all', async () => { + const created: any = await protocol.createData({ object: DEAL.name, data: { body: 'hi' } }); + const answer: any = await protocol.updateData({ object: DEAL.name, id: created.id, data: { body: 'edited' } }); + expect('notice' in answer).toBe(false); + }); +}); + +describe('[#28000] a declarative `handler` reaches the same listener through its flat record view', () => { + beforeEach(() => boot('declarative')); + + it('a beforeInsert handler sets the create answer\'s notice', async () => { + const answer: any = await protocol.createData({ object: DEAL.name, data: { body: 'hi', say: 'Sent for approval' } }); + expect(answer.notice).toBe('Sent for approval'); + }); + + it('an afterUpdate handler sets the update answer\'s notice', async () => { + const created: any = await protocol.createData({ object: DEAL.name, data: { body: 'hi' } }); + const answer: any = await protocol.updateData({ object: DEAL.name, id: created.id, data: { say_after: 'Approval requested' } }); + expect(answer.notice).toBe('Approval requested'); + }); +}); diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 4b81acba36b..d009855db44 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -340,6 +340,7 @@ "api/CreateDataRequest:object", "api/CreateDataResponse:droppedFields", "api/CreateDataResponse:id", + "api/CreateDataResponse:notice", "api/CreateDataResponse:object", "api/CreateDataResponse:record", "api/CreateDataResponse:warnings", @@ -1737,6 +1738,7 @@ "api/UpdateDataRequest:object", "api/UpdateDataResponse:droppedFields", "api/UpdateDataResponse:id", + "api/UpdateDataResponse:notice", "api/UpdateDataResponse:object", "api/UpdateDataResponse:record", "api/UpdateDataResponse:warnings", diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 537c7ec4c0b..50c901126a1 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -2655,6 +2655,21 @@ const WRITE_ANSWER_WITHHELD_MENTIONS_DESCRIPTION = '(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.'; +/** + * The `notice` member the create and update answers declare (#28000) — one + * sentence, so the two answers cannot drift apart. Response-only: it is no + * field of any object, it is never stored, and a request never sends it. + */ +const WRITE_ANSWER_NOTICE_DESCRIPTION = + '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.'; + /** * Create Data Response */ @@ -2674,6 +2689,7 @@ export const CreateDataResponseSchema = lazySchema(() => z.object({ ), warnings: z.array(ValidateDataIssueSchema).optional().describe(WRITE_ANSWER_WARNINGS_DESCRIPTION), withheldMentions: z.array(z.string()).optional().describe(WRITE_ANSWER_WITHHELD_MENTIONS_DESCRIPTION), + notice: z.string().min(1).optional().describe(WRITE_ANSWER_NOTICE_DESCRIPTION), })); /** @@ -2856,6 +2872,7 @@ export const UpdateDataResponseSchema = lazySchema(() => z.object({ ), warnings: z.array(ValidateDataIssueSchema).optional().describe(WRITE_ANSWER_WARNINGS_DESCRIPTION), withheldMentions: z.array(z.string()).optional().describe(WRITE_ANSWER_WITHHELD_MENTIONS_DESCRIPTION), + notice: z.string().min(1).optional().describe(WRITE_ANSWER_NOTICE_DESCRIPTION), })); /** diff --git a/packages/spec/src/api/write-answer-notice.test.ts b/packages/spec/src/api/write-answer-notice.test.ts new file mode 100644 index 00000000000..6a413a32dcf --- /dev/null +++ b/packages/spec/src/api/write-answer-notice.test.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28000] The save-notice write answer, at the contract. + * + * A hook running on a record save tells the person who saved what the save set + * in motion ("Sent to your manager for approval"). The create and update + * answers carry that one author-written message as `notice`: info severity, + * distinct from `warnings`, omitted when no hook set one. These pins hold the + * member optional on both answers (an existing client's reading does not + * move), hold it to one non-empty message, and hold the in-process listener a + * hook calls to set it. + */ + +import { describe, it, expect } from 'vitest'; +import { + CreateDataResponseSchema, + UpdateDataResponseSchema, + CloneDataResponseSchema, +} from './protocol.zod'; +import type { WriteObservabilityOptions } from '../contracts/data-engine'; + +const base = { object: 'deal', id: 'd1', record: { id: 'd1' } }; +const shapeKeys = (schema: unknown) => Object.keys((schema as { shape: Record }).shape); +const describeOf = (schema: unknown) => + ((schema as { shape: Record }).shape.notice?.description ?? ''); + +describe('[#28000] the create and update answers declare an optional one-message `notice`', () => { + it.each([ + ['CreateDataResponseSchema', CreateDataResponseSchema], + ['UpdateDataResponseSchema', UpdateDataResponseSchema], + ] as const)('%s accepts the message, and an answer with no key', (_name, schema) => { + expect(schema.safeParse({ ...base, notice: 'Sent to your manager for approval' })) + .toMatchObject({ success: true, data: { notice: 'Sent to your manager for approval' } }); + expect(schema.safeParse(base)).toMatchObject({ success: true }); + expect((schema.parse(base) as Record).notice).toBeUndefined(); + }); + + it.each([ + ['CreateDataResponseSchema', CreateDataResponseSchema], + ['UpdateDataResponseSchema', UpdateDataResponseSchema], + ] as const)('%s refuses anything but one non-empty message: no list, no object, no empty string', (_name, schema) => { + expect(schema.safeParse({ ...base, notice: ['a', 'b'] }).success).toBe(false); + expect(schema.safeParse({ ...base, notice: { message: 'a', severity: 'info' } }).success).toBe(false); + expect(schema.safeParse({ ...base, notice: '' }).success).toBe(false); + }); + + it('it is a member of its own, beside `warnings`, never folded into it', () => { + for (const schema of [CreateDataResponseSchema, UpdateDataResponseSchema]) { + expect(shapeKeys(schema)).toEqual(expect.arrayContaining(['warnings', 'notice'])); + } + }); + + it('the clone answer does not declare it: the card names the single-record create and update answers', () => { + expect(shapeKeys(CloneDataResponseSchema)).not.toContain('notice'); + }); + + it('the description is one sentence shared by both answers, and says it is never stored', () => { + expect(describeOf(CreateDataResponseSchema)).toBe(describeOf(UpdateDataResponseSchema)); + expect(describeOf(CreateDataResponseSchema)).toMatch(/never\s+stored/); + expect(describeOf(CreateDataResponseSchema)).toMatch(/LAST one wins/); + }); +}); + +describe('[#28000] the listener is declared on the in-process write contract', () => { + it('`onNotice` takes one message; absent stays legal', () => { + const seen: string[] = []; + const opts: WriteObservabilityOptions = { onNotice: (message) => { seen.push(message); } }; + opts.onNotice?.('Sent to your manager for approval'); + expect(seen).toEqual(['Sent to your manager for approval']); + const unset: WriteObservabilityOptions = {}; + expect(unset.onNotice).toBeUndefined(); + }); +}); diff --git a/packages/spec/src/contracts/data-engine.ts b/packages/spec/src/contracts/data-engine.ts index c31b1ba8ee3..8be469b2a4a 100644 --- a/packages/spec/src/contracts/data-engine.ts +++ b/packages/spec/src/contracts/data-engine.ts @@ -124,6 +124,46 @@ export interface WriteObservabilityOptions { */ onMentionsWithheld?: (userIds: readonly string[]) => void; + /** + * The channel a hook uses to tell the person who made this write what the + * save set in motion (#28000) — "Sent to your manager for approval", "A + * renewal opportunity was created". One author-written message, info + * severity: it reports an outcome, never a problem, so it is not a + * validation finding and never rides `onValidationAdvisory`. + * `metadata-protocol`'s `createData` and `updateData` pass one and answer the + * message as `notice`, on the requester's own answer to that request and + * nowhere else. + * + * The ENGINE never calls it. A hook reaches it the way every member of this + * bag reaches a hook — on `ctx.input.options`, the caller's own bag, which a + * `before*` handler reads as passed and an `after*` handler reads after + * `buildDriverOptions` widened it (it never strips a key) — and calls it + * with the message (`ctx.input` is typed as a plain record, so the bag is + * cast to this interface, as the platform's own hooks do): + * + * const opts = ctx.input.options as WriteObservabilityOptions | undefined; + * opts?.onNotice?.('Sent to your manager for approval'); + * + * Absent is normal: a write no person is waiting on (a seed, a flow node, a + * batch, an import) passes no listener, so a hook always calls it + * optionally. The sandboxed `body` face (including a `handler` the build + * lowers into a body) does not carry it: its `ctx.input.options` is the + * frozen `{ multi, where }` projection, never the caller's bag. + * + * One message per write: a later call REPLACES an earlier one, so the last + * call before the write answers wins, and a message that is not a non-empty + * string sets nothing. A write that is refused answers its error, never a + * notice, even if a `before*` hook set one first. A nested write a hook or a + * flow makes inside this one carries its own options, so its notice never + * reaches this listener. Nothing is stored. + * + * In-process only, like `onFieldsDropped`: a function cannot cross the RPC + * boundary. Because the hook calls the listener directly, nothing between + * them catches, so a listener must only record the message and never throw + * — `metadata-protocol`'s does exactly that. + */ + onNotice?: (message: string) => void; + /** * Refuse the write instead of stripping (#5126). Default `false` — off. * diff --git a/packages/spec/src/data/hook-api.ts b/packages/spec/src/data/hook-api.ts index a1931e87f57..f0f7e6bd86d 100644 --- a/packages/spec/src/data/hook-api.ts +++ b/packages/spec/src/data/hook-api.ts @@ -191,9 +191,9 @@ export type HookUpdateDoc = HookDoc; * * `EngineUpdateOptions` minus the injected `context` and the `upsert` * tombstone, plus {@link WriteObservabilityOptions} (`onFieldsDropped`, - * `onValidationAdvisory`, `onMentionsWithheld`, `strictReadonlyWrites` — contract-declared, - * deliberately outside the serializable schema) and the driver pass-through - * keys. + * `onValidationAdvisory`, `onMentionsWithheld`, `onNotice`, + * `strictReadonlyWrites` — contract-declared, deliberately outside the + * serializable schema) and the driver pass-through keys. * * The bulk form is `update(data, { where, multi: true })`; there is no * `updateMany`.