Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion scripts/check-package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MessageRecord> = {",
' template: "You were told: {{said}}\\n{{> footer}}",',
' session: (signal: Signal<MessageRecord>) => "user_" + signal.payload.userId,',
" data: (signal: Signal<MessageRecord>) => ({ said: signal.payload.text }),",
" helpers: { shout: (value: string) => value.toUpperCase() },",
' partials: { footer: "-- sent by the Gateway" },',
" post: (signal: Signal<MessageRecord>, outcome: PostOutcome) => {",
' if (outcome.failed) log.warn({ userId: signal.payload.userId }, "nothing was sent");',
" },",
"};",
"const fromTemplate: SignalHandler<MessageRecord> = templateHandler(promptOptions);",
"",
Expand Down
3 changes: 2 additions & 1 deletion site/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
107 changes: 106 additions & 1 deletion src/signals/template-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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<string, PostOutcome> = {};
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"]);
});
});
});
33 changes: 27 additions & 6 deletions src/signals/template-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TPayload = unknown> = {
/**
Expand Down Expand Up @@ -81,6 +81,21 @@ export type TemplateHandlerOptions<TPayload = unknown> = {
* hold inside them too.
*/
readonly partials?: Readonly<Record<string, string>>;

/**
* 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<TPayload>, outcome: PostOutcome) => void | Promise<void>;
};

// Both are load-bearing and both are argued in the file header. What they do to a template is
Expand All @@ -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
Expand Down Expand Up @@ -141,6 +157,11 @@ export function templateHandler<TPayload = unknown>(

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 }),
};
}

Expand Down
Loading