diff --git a/scripts/check-package.ts b/scripts/check-package.ts index e98ba1f..04640b6 100644 --- a/scripts/check-package.ts +++ b/scripts/check-package.ts @@ -1024,13 +1024,18 @@ try { "//. The options are annotated separately, so a field that went missing from", "// the declaration fails here rather than being silently ignored. What is", "// being checked is that the declaration accepts template source, a Session-naming", - "// function, a data function, helpers and partials.", + "// function, a data function, helpers, partials and a post phase, and that the", + "// payload type reaches the post phase the way it reaches the other two that are", + "// handed a Signal.", "const promptOptions: TemplateHandlerOptions = {", ' template: "You were told: {{said}}\\n{{> footer}}",', ' session: (signal: Signal) => "user_" + signal.payload.userId,', " data: (signal: Signal) => ({ said: signal.payload.text }),", " helpers: { shout: (value: string) => value.toUpperCase() },", ' partials: { footer: "-- sent by the Gateway" },', + " post: (signal: Signal, outcome: PostOutcome) => {", + ' if (outcome.failed) log.warn({ userId: signal.payload.userId }, "nothing was sent");', + " },", "};", "const fromTemplate: SignalHandler = templateHandler(promptOptions);", "", diff --git a/site/architecture.md b/site/architecture.md index 7f38ed0..fee1e76 100644 --- a/site/architecture.md +++ b/site/architecture.md @@ -278,7 +278,8 @@ the person who asked. `templateHandler` is the common case. It renders a Handlebars template into one Prompt. The template is source text, never a path, and it compiles when the Gateway is built. A template that -does not compile therefore fails construction rather than a Signal. +does not compile therefore fails construction rather than a Signal. It takes a post phase as an +option and hands it over as written. ### At-most-once diff --git a/src/signals/template-handler.test.ts b/src/signals/template-handler.test.ts index 1b2efc6..e143b4a 100644 --- a/src/signals/template-handler.test.ts +++ b/src/signals/template-handler.test.ts @@ -24,7 +24,7 @@ import { applySchema } from "../test-support/apply-schema.ts"; import { createTestDatabase, type TestDatabase } from "../test-support/database.ts"; import { type FakeRuntime, fakeRuntime } from "../test-support/fake-runtime.ts"; import { waitUntil } from "../test-support/wait.ts"; -import type { Prompt, Signal, SignalHandler, SignalHandlers } from "./handlers.ts"; +import type { PostOutcome, Prompt, Signal, SignalHandler, SignalHandlers } from "./handlers.ts"; import * as signalsSchema from "./schema/index.ts"; import { signals } from "./schema/index.ts"; import { templateHandler } from "./template-handler.ts"; @@ -354,6 +354,42 @@ describe("a Handler's Handlebars environment", () => { }); }); +/** + * The post phase, as far as it can be seen without a worker: the Handler carries the + * Operator's function and nothing more. + * + * The absence assertion is the one with teeth. The worker asks `handler.post !== undefined` + * before running a post phase, so a Handler built without one that carries the property + * anyway would run a phase nobody wrote, and `exactOptionalPropertyTypes` is the only thing + * standing between those two spellings. + */ +describe("the template Handler's post phase", () => { + it("is absent from a Handler built without one", () => { + const handler = handlerOver("a Prompt that renders"); + + assert.equal("post" in handler, false, "no post phase should be carried"); + assert.equal(handler.post, undefined); + }); + + it("is the Operator's own function, carried across unwrapped", async () => { + const seen: { signal: Signal<{ readonly userId: string }>; outcome: PostOutcome }[] = []; + const post = (signal: Signal<{ readonly userId: string }>, outcome: PostOutcome): void => { + seen.push({ signal, outcome }); + }; + const handler = templateHandler<{ readonly userId: string }>({ + template: "a Prompt that renders", + session: () => null, + data: () => ({}), + post, + }); + + assert.equal(handler.post, post, "the function should reach the Handler as it was written"); + const signal = aSignal({ userId: "u_1" }); + await handler.post?.(signal, { failed: true }); + assert.deepEqual(seen, [{ signal, outcome: { failed: true } }]); + }); +}); + /** * The two criteria that are about the Signal log rather than the Handler. * @@ -468,4 +504,73 @@ describe("the template Handler under the worker", () => { ); }); }); + + /** + * What the post phase is told, for the two outcomes a template Handler can reach: a Run + * that ran, and a template that did not render and so produced no Run at all. The second + * is the whole reason an Operator writes one here, since a Signal is never retried. + */ + it("runs the post phase after the Run, and again when no Run was ever created", async () => { + const outcomes: Record = {}; + const post = + (kind: string) => + (_signal: Signal, outcome: PostOutcome): void => { + outcomes[kind] = outcome; + }; + const handlers: SignalHandlers = { + "prompt.good": templateHandler({ + template: "a Prompt that renders", + session: () => null, + data: () => ({}), + post: post("prompt.good"), + }), + "prompt.unsupplied": templateHandler({ + template: "Hello {{whoever}}", + session: () => null, + data: () => ({}), + post: post("prompt.unsupplied"), + }), + }; + + await withWorker(handlers, async (worker, runtime) => { + const good = await emit(worker, "prompt.good"); + const unsupplied = await emit(worker, "prompt.unsupplied"); + + assert.deepEqual(await settled(good), { state: "done", error: null }); + assert.equal((await settled(unsupplied)).state, "failed"); + + assert.deepEqual(outcomes, { + "prompt.good": { failed: false }, + "prompt.unsupplied": { failed: true }, + }); + // The Run had finished before its post phase was told about it. + assert.deepEqual(runtime.texts(), ["a Prompt that renders"]); + }); + }); + + /** + * A post phase that throws reaches the Signal log in the Operator's own words. Nothing + * here wraps it, so the reason must not read as a template that failed to render: the + * template rendered, and a reader chasing the wrong file is the damage. + */ + it("records what a failing post phase said, and says nothing about the template", async () => { + const handler = templateHandler({ + template: "a Prompt that renders", + session: () => null, + data: () => ({}), + post: () => { + throw new Error("the notification could not be delivered"); + }, + }); + + await withWorker({ "prompt.render": handler }, async (worker, runtime) => { + const signalId = await emit(worker, "prompt.render"); + const outcome = await settled(signalId); + + assert.equal(outcome.state, "failed"); + assert.equal(outcome.error, "the post phase failed: the notification could not be delivered"); + // The Prompt itself was rendered and run: only the phase after it failed. + assert.deepEqual(runtime.texts(), ["a Prompt that renders"]); + }); + }); }); diff --git a/src/signals/template-handler.ts b/src/signals/template-handler.ts index fe9ffcb..1585204 100644 --- a/src/signals/template-handler.ts +++ b/src/signals/template-handler.ts @@ -25,13 +25,13 @@ * `#if` block with none throws from the built-in helper, which runs only with a context, so it * stays a render failure and there is no place to move it to. * - * The two Operator callbacks are awaited outside the try/catch below on purpose. An error thrown by - * one of them is theirs to recognise, and wrapping it in a sentence about a template would put our - * words on their bug. + * The Operator's own callbacks are awaited outside the try/catch below on purpose, and `post` is + * handed to the Worker unwrapped for the same reason. An error thrown by one of them is theirs to + * recognise, and wrapping it in a sentence about a template would put our words on their bug. */ import Handlebars from "handlebars"; -import type { Prompt, Signal, SignalHandler } from "./handlers.ts"; +import type { PostOutcome, Prompt, Signal, SignalHandler } from "./handlers.ts"; export type TemplateHandlerOptions = { /** @@ -81,6 +81,21 @@ export type TemplateHandlerOptions = { * hold inside them too. */ readonly partials?: Readonly>; + + /** + * The post phase, run once after every Run arising from the Signal has finished, whether they + * succeeded, failed or were never created. It produces no Prompts. + * + * It is the whole of the framework's failure handling, because nothing retries a Signal: a + * Signal that came to nothing came to nothing for good, and telling somebody so is written here + * or nowhere. `outcome.failed` is true if any Run failed and true as well if the render failed + * before there were any. + * + * It is carried to the Signal Worker as written and never called from here, and what it throws + * is not wrapped: the Worker records it as `the post phase failed: ...`, beside whatever else + * went wrong. What it closes over is yours, exactly as the Handler contract has it. + */ + readonly post?: (signal: Signal, outcome: PostOutcome) => void | Promise; }; // Both are load-bearing and both are argued in the file header. What they do to a template is @@ -91,8 +106,9 @@ const compileOptions: CompileOptions = { noEscape: true, strict: true }; * Builds a Signal Handler that renders one Prompt per Signal from a Handlebars template. * * One Prompt, always. It never fans a Signal out across several Sessions and never declines one, - * although the Handler contract allows both. It has no post phase either, and gains one by being - * spread: `{ ...templateHandler(options), post }` is a Handler. + * although the Handler contract allows both. A post phase it does carry: `post` is an option here + * and reaches the Worker as the Handler's own, so the Signal's payload is narrowed in it the way + * it is in `session` and `data`. * * A template that compiles and then does not render fails the Signal, with a message naming the * Signal's kind. Handlebars names the variable, the line and the column, and never says which @@ -141,6 +157,11 @@ export function templateHandler( return [{ session, text }]; }, + + // Spread rather than `post: options.post`, which `exactOptionalPropertyTypes` refuses against + // an optional method: a Handler that was given no post phase must not carry the property at + // all, because the Worker asks `handler.post !== undefined` before running one. + ...(options.post === undefined ? {} : { post: options.post }), }; }