@pithy-sh/support 0.3.3 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/ai/classify.d.ts.map +1 -1
  2. package/dist/ai/classify.js +3 -3
  3. package/dist/http/handlers.d.ts.map +1 -1
  4. package/dist/http/handlers.js +2 -3
  5. package/dist/http/routes.d.ts +4 -1
  6. package/dist/http/routes.d.ts.map +1 -1
  7. package/dist/http/routes.js +60 -12
  8. package/dist/inbound/handler.d.ts.map +1 -1
  9. package/dist/inbound/handler.js +0 -1
  10. package/dist/inbound/ingest.d.ts +0 -2
  11. package/dist/inbound/ingest.d.ts.map +1 -1
  12. package/dist/inbound/ingest.js +0 -14
  13. package/dist/provision/provisionSupport.d.ts +25 -4
  14. package/dist/provision/provisionSupport.d.ts.map +1 -1
  15. package/dist/provision/provisionSupport.js +18 -3
  16. package/dist/reply/send.d.ts +0 -2
  17. package/dist/reply/send.d.ts.map +1 -1
  18. package/dist/reply/send.js +2 -15
  19. package/dist/store/search.d.ts +16 -9
  20. package/dist/store/search.d.ts.map +1 -1
  21. package/dist/store/search.js +16 -9
  22. package/dist/store/searchIndex.d.ts +131 -5
  23. package/dist/store/searchIndex.d.ts.map +1 -1
  24. package/dist/store/searchIndex.js +151 -6
  25. package/dist/store/threads.d.ts.map +1 -1
  26. package/dist/store/threads.js +3 -2
  27. package/dist/submission/submit.d.ts +0 -2
  28. package/dist/submission/submit.d.ts.map +1 -1
  29. package/dist/submission/submit.js +0 -14
  30. package/dist/version.generated.d.ts +1 -1
  31. package/dist/version.generated.js +1 -1
  32. package/package.json +10 -10
  33. package/src/ai/classify.ts +7 -3
  34. package/src/http/handlers.ts +2 -3
  35. package/src/http/routes.ts +96 -19
  36. package/src/inbound/handler.ts +0 -1
  37. package/src/inbound/ingest.ts +0 -14
  38. package/src/provision/provisionSupport.ts +33 -7
  39. package/src/reply/send.ts +2 -13
  40. package/src/store/search.ts +16 -9
  41. package/src/store/searchIndex.ts +176 -5
  42. package/src/store/threads.ts +2 -1
  43. package/src/submission/submit.ts +0 -11
  44. package/src/version.generated.ts +1 -1
@@ -1,12 +1,12 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
- import { zValidator } from "@hono/zod-validator";
5
4
  import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
6
5
  import { requireControlPlane } from "@pithy-sh/core/src/controlPlane/http/guard";
7
6
  import { InternalError } from "@pithy-sh/core/src/error/pithyError";
7
+ import { describeRoute } from "@pithy-sh/core/src/http/routeSpec";
8
8
  import { requireSameOrigin } from "@pithy-sh/core/src/http/sameOrigin";
9
- import { validationHook } from "@pithy-sh/core/src/http/validation";
9
+ import { validate } from "@pithy-sh/core/src/http/validation";
10
10
  import type { Context, Hono } from "hono";
11
11
  import { requireAuth } from "./guards";
12
12
  import {
@@ -22,7 +22,18 @@ import {
22
22
  submitFeedbackRequest,
23
23
  updateFlags,
24
24
  } from "./handlers";
25
- import type { SupportRepliesResponse } from "./responses";
25
+ import {
26
+ SupportArchiveResponse,
27
+ SupportFlagsResponse,
28
+ SupportMyThreadResponse,
29
+ SupportMyThreadsResponse,
30
+ SupportReclassifiedResponse,
31
+ SupportRepliesResponse,
32
+ SupportReplySentResponse,
33
+ SupportSubmissionResponse,
34
+ SupportThreadResponse,
35
+ SupportThreadsResponse,
36
+ } from "./responses";
26
37
  import {
27
38
  ArchiveThreadInput,
28
39
  FlagsInput,
@@ -114,7 +125,10 @@ import {
114
125
  * `createBackend` mounts **every** capability's middleware before **any** capability's routes, and the
115
126
  * adopter's `app` capability composes last — so their middleware runs after auth has resolved the
116
127
  * session and before this file's `requireAuth()`. `createBackend.workers.test.ts` pins that ordering,
117
- * because a paragraph asserting it is not the same as a test failing when it changes.
128
+ * because a paragraph asserting it is not the same as a test failing when it changes. Readiness guards
129
+ * (`Capability.readiness`) mount between the two, after every middleware and before every route, and
130
+ * skip the admin surface; `createBackend.readiness.workers.test.ts` pins that. A check like this one
131
+ * refuses a caller, not a broken Worker, so it stays in `middleware`.
118
132
  *
119
133
  * Two consequences worth stating rather than discovering. Their middleware sees `c.var.auth` as **null**
120
134
  * on an unauthenticated request, since it runs ahead of the route's own gate — so it should pass those
@@ -192,40 +206,70 @@ export function registerSupportRoutes(options: SupportRoutesOptions): (app: Hono
192
206
  return (app) => {
193
207
  app.get(
194
208
  `${base}/threads`,
209
+ describeRoute({
210
+ summary: "List the support inbox.",
211
+ verification: "control-plane",
212
+ scopes: [SUPPORT_THREADS_READ_SCOPE],
213
+ responses: { 200: SupportThreadsResponse },
214
+ }),
195
215
  requireControlPlane(SUPPORT_THREADS_READ_SCOPE),
196
- zValidator("query", ListThreadsQuery, validationHook),
216
+ validate("query", ListThreadsQuery),
197
217
  async (c) => c.json(await listInbox(await resolve(c), c.req.valid("query"), viewer(c))),
198
218
  );
199
219
 
200
220
  app.get(
201
221
  `${base}/replies`,
222
+ describeRoute({
223
+ summary: "List the canned replies.",
224
+ verification: "control-plane",
225
+ scopes: [SUPPORT_THREADS_READ_SCOPE],
226
+ responses: { 200: SupportRepliesResponse },
227
+ }),
202
228
  requireControlPlane(SUPPORT_THREADS_READ_SCOPE),
203
- zValidator("query", RepliesQuery, validationHook),
229
+ validate("query", RepliesQuery),
204
230
  async (c) =>
205
231
  c.json({ replies: listReplies(await resolve(c), c.req.valid("query")) } satisfies SupportRepliesResponse),
206
232
  );
207
233
 
208
234
  app.get(
209
235
  `${base}/threads/:id`,
236
+ describeRoute({
237
+ summary: "Read one conversation and its messages.",
238
+ verification: "control-plane",
239
+ scopes: [SUPPORT_THREADS_READ_SCOPE],
240
+ responses: { 200: SupportThreadResponse },
241
+ }),
210
242
  requireControlPlane(SUPPORT_THREADS_READ_SCOPE),
211
- zValidator("param", ThreadIdParam, validationHook),
243
+ validate("param", ThreadIdParam),
212
244
  async (c) => c.json(await readConversation(await resolve(c), c.req.valid("param").id)),
213
245
  );
214
246
 
215
247
  app.post(
216
248
  `${base}/threads/:id/archive`,
249
+ describeRoute({
250
+ summary: "Mark a conversation done, or reopen it.",
251
+ verification: "control-plane",
252
+ scopes: [SUPPORT_THREADS_ARCHIVE_SCOPE],
253
+ responses: { 200: SupportArchiveResponse },
254
+ }),
217
255
  requireControlPlane(SUPPORT_THREADS_ARCHIVE_SCOPE),
218
- zValidator("param", ThreadIdParam, validationHook),
219
- zValidator("json", ArchiveThreadInput, validationHook),
256
+ validate("param", ThreadIdParam),
257
+ validate("json", ArchiveThreadInput),
220
258
  async (c) =>
221
259
  c.json(await archiveConversation(await resolve(c), c.req.valid("param").id, c.req.valid("json"), viewer(c))),
222
260
  );
223
261
 
224
262
  app.post(
225
263
  `${base}/threads/:id/reply`,
264
+ describeRoute({
265
+ summary: "Answer the customer.",
266
+ verification: "control-plane",
267
+ scopes: [SUPPORT_THREADS_REPLY_SCOPE],
268
+ responses: { 201: SupportReplySentResponse },
269
+ }),
226
270
  requireControlPlane(SUPPORT_THREADS_REPLY_SCOPE),
227
- zValidator("param", ThreadIdParam, validationHook),
228
- zValidator("json", ReplyInput, validationHook),
271
+ validate("param", ThreadIdParam),
272
+ validate("json", ReplyInput),
229
273
  async (c) =>
230
274
  c.json(
231
275
  await replyToConversation(await resolve(c), c.req.valid("param").id, c.req.valid("json"), viewer(c)),
@@ -235,16 +279,28 @@ export function registerSupportRoutes(options: SupportRoutesOptions): (app: Hono
235
279
 
236
280
  app.post(
237
281
  `${base}/threads/:id/reclassify`,
282
+ describeRoute({
283
+ summary: "Run the classifier again on the latest inbound message.",
284
+ verification: "control-plane",
285
+ scopes: [SUPPORT_THREADS_RECLASSIFY_SCOPE],
286
+ responses: { 200: SupportReclassifiedResponse },
287
+ }),
238
288
  requireControlPlane(SUPPORT_THREADS_RECLASSIFY_SCOPE),
239
- zValidator("param", ThreadIdParam, validationHook),
289
+ validate("param", ThreadIdParam),
240
290
  async (c) => c.json(await reclassifyConversation(await resolve(c), c.req.valid("param").id, viewer(c))),
241
291
  );
242
292
 
243
293
  app.post(
244
294
  `${base}/threads/:id/flags`,
295
+ describeRoute({
296
+ summary: "Set the caller's private read and snooze flags.",
297
+ verification: "control-plane",
298
+ scopes: [SUPPORT_THREADS_FLAG_SCOPE],
299
+ responses: { 200: SupportFlagsResponse },
300
+ }),
245
301
  requireControlPlane(SUPPORT_THREADS_FLAG_SCOPE),
246
- zValidator("param", ThreadIdParam, validationHook),
247
- zValidator("json", FlagsInput, validationHook),
302
+ validate("param", ThreadIdParam),
303
+ validate("json", FlagsInput),
248
304
  async (c) => c.json(await updateFlags(await resolve(c), c.req.valid("param").id, c.req.valid("json"), viewer(c))),
249
305
  );
250
306
 
@@ -255,18 +311,39 @@ export function registerSupportRoutes(options: SupportRoutesOptions): (app: Hono
255
311
  // unauthenticated caller which submissions were well-formed.
256
312
  app.post(
257
313
  `${base}/feedback`,
314
+ describeRoute({
315
+ summary: "Write in to support from the app.",
316
+ verification: ["bearer", "session"],
317
+ responses: { 201: SupportSubmissionResponse },
318
+ }),
258
319
  requireAuth(),
259
320
  requireSameOrigin(),
260
- zValidator("json", SubmitFeedbackInput, validationHook),
321
+ validate("json", SubmitFeedbackInput),
261
322
  async (c) => c.json(await submitFeedbackRequest(await resolve(c), c.req.valid("json"), submitter(c)), 201),
262
323
  );
263
324
 
264
- app.get(`${base}/feedback`, requireAuth(), zValidator("query", MyThreadsQuery, validationHook), async (c) =>
265
- c.json(await listMyThreads(await resolve(c), c.req.valid("query"), submitter(c))),
325
+ app.get(
326
+ `${base}/feedback`,
327
+ describeRoute({
328
+ summary: "List the caller's own support requests.",
329
+ verification: ["bearer", "session"],
330
+ responses: { 200: SupportMyThreadsResponse },
331
+ }),
332
+ requireAuth(),
333
+ validate("query", MyThreadsQuery),
334
+ async (c) => c.json(await listMyThreads(await resolve(c), c.req.valid("query"), submitter(c))),
266
335
  );
267
336
 
268
- app.get(`${base}/feedback/:id`, requireAuth(), zValidator("param", ThreadIdParam, validationHook), async (c) =>
269
- c.json(await readMyThread(await resolve(c), c.req.valid("param").id, submitter(c))),
337
+ app.get(
338
+ `${base}/feedback/:id`,
339
+ describeRoute({
340
+ summary: "Read one of the caller's own support requests.",
341
+ verification: ["bearer", "session"],
342
+ responses: { 200: SupportMyThreadResponse },
343
+ }),
344
+ requireAuth(),
345
+ validate("param", ThreadIdParam),
346
+ async (c) => c.json(await readMyThread(await resolve(c), c.req.valid("param").id, submitter(c))),
270
347
  );
271
348
  };
272
349
  }
@@ -74,7 +74,6 @@ export function createSupportEmailHandler(wiring: SupportWiring): CapabilityEmai
74
74
  db: supportDatabase(d1),
75
75
  config: wiring.config,
76
76
  bucket: bindings.SUPPORT_BUCKET,
77
- fts: wiring.config.search.fts,
78
77
  dispatchClassify: makeClassifyDispatcher(env, log),
79
78
  linkSender: (address) => resolveSenderUserId(d1, address, wiring.peers),
80
79
  emit: wiring.emit,
@@ -21,7 +21,6 @@ import { MAX_TEXT_BODY, type ParsedInboundMessage, parseInbound } from "../mime/
21
21
  import { htmlToText, sanitizeHtml } from "../mime/sanitize";
22
22
  import { parentCandidates } from "../mime/threading";
23
23
  import { truncateToBytes } from "../mime/truncate";
24
- import { indexMessage } from "../store/search";
25
24
  import { senderAuthenticity } from "./authenticity";
26
25
  import { checkRates, checkSize } from "./guard";
27
26
  import { resolveInbox } from "./recipient";
@@ -83,8 +82,6 @@ export interface IngestDeps {
83
82
  config: SupportConfig;
84
83
  /** The R2 bucket raw messages and attachments are written to. Absent means neither is kept. */
85
84
  bucket?: R2Bucket;
86
- /** Whether the FTS5 index is composed. False means search runs as a `LIKE` scan and nothing is indexed. */
87
- fts: boolean;
88
85
  /**
89
86
  * Start the classification Workflow for a stored message. Allowed to fail and allowed to decline —
90
87
  * an unprovisioned project has no binding, and a thread that stays `uncategorized` is a legitimate
@@ -504,17 +501,6 @@ export async function ingestInbound(deps: IngestDeps, input: IngestInput): Promi
504
501
  throw error;
505
502
  }
506
503
 
507
- // The full-text index, when it is composed. Best-effort by contract: a failed index write must
508
- // never lose a customer's message, so it is logged and the ingest continues. The row is stored,
509
- // readable, and repairable by `reindexThread` — it is only missing from one search box.
510
- if (deps.fts) {
511
- try {
512
- await indexMessage(deps.db, { threadId, messageId, subject, body: text });
513
- } catch (error) {
514
- deps.log.warn("support message not indexed", { messageId, error });
515
- }
516
- }
517
-
518
504
  // Guarded for the same reason the index write and the dispatch are, and the consequence here is
519
505
  // the worst of the three. This runs *after* the message and thread rows are committed but *before*
520
506
  // the classification dispatch — so a transient `bucket.put` failure throws out of ingest, the
@@ -5,6 +5,7 @@ import type { DeclaredEnvironments } from "@pithy-sh/core/src/naming/environment
5
5
  import { GLOBAL_SCOPE } from "@pithy-sh/core/src/naming/environment";
6
6
  import { resourceName } from "@pithy-sh/core/src/naming/resource";
7
7
  import { resourceNames } from "@pithy-sh/core/src/naming/resourceNames";
8
+ import { hostWorkflowsFor } from "@pithy-sh/core/src/workflow/host";
8
9
  import {
9
10
  assertSharedLeavesLast,
10
11
  type DeprovisionTarget,
@@ -12,7 +13,7 @@ import {
12
13
  type ManagedEnvironment,
13
14
  managedEnvironments,
14
15
  } from "@pithy-sh/secrets/src/scope";
15
- import { SUPPORT_CAPABILITY } from "../workflows/specs";
16
+ import { SUPPORT_CAPABILITY, supportWorkflowRegistry } from "../workflows/specs";
16
17
 
17
18
  /**
18
19
  * The provisioning orchestration for the support capability — the live counterpart to
@@ -67,6 +68,18 @@ export function supportWorkerName(project: string, env: ManagedEnvironment): str
67
68
  return resourceNames(project).env(env).worker(SUPPORT_CAPABILITY);
68
69
  }
69
70
 
71
+ /**
72
+ * The Workflows the classification worker is deployed with in one environment —
73
+ * `<project>-<env>-support-classify`. Derived by the call `resolveSupportConfig` deploys with, so teardown
74
+ * deletes the names provision created and no others (#789). Deleting a Worker does not delete the
75
+ * Workflows registered against it.
76
+ */
77
+ export function supportWorkflowNames(project: string, env: ManagedEnvironment): string[] {
78
+ return hostWorkflowsFor(supportWorkflowRegistry, { project, capability: SUPPORT_CAPABILITY, env }).workflows.map(
79
+ (workflow) => workflow.name,
80
+ );
81
+ }
82
+
70
83
  /** The live Cloudflare/wrangler seam. Each step must be idempotent. */
71
84
  export interface SupportProvisioner {
72
85
  /**
@@ -84,15 +97,22 @@ export interface SupportProvisioner {
84
97
  /** Deploy the prebuilt classification worker for this environment. */
85
98
  deployWorker(env: ManagedEnvironment): Promise<void>;
86
99
  /**
87
- * Create or drop the full-text index in this environment's app database, to match `search.fts`.
100
+ * Create or drop the full-text index **and its triggers** in this environment's app database, to
101
+ * match `search.fts`.
88
102
  *
89
103
  * A provisioning step rather than a migration, because the index is **derived** — every row in it
90
104
  * comes from `pithy_support_messages` and `reindexThread` rebuilds it on demand. That is the line a
91
105
  * migration is for: schema whose loss loses data. Keeping it here is also what makes the flag safe
92
106
  * to toggle at all, since a config-conditional migration removed from the set is corruption to
93
- * Kysely and blocks `pithy migrate` for every capability sharing the database.
107
+ * Kysely and blocks `pithy migrate` for every capability sharing the database. The triggers inherit
108
+ * that constraint exactly: they exist only where the table does, so they are provisioning too.
109
+ *
110
+ * **Three outcomes, not two.** `repaired` is a database whose table was already there and whose
111
+ * triggers were not — an adopter who took the release that moved the index onto triggers and deployed
112
+ * without re-provisioning. Nothing in the kit maintains that index, so the run creates the triggers
113
+ * and backfills the gap, and says which of the three it did.
94
114
  */
95
- ensureSearchIndex(env: ManagedEnvironment): Promise<{ created: boolean; dropped: boolean }>;
115
+ ensureSearchIndex(env: ManagedEnvironment): Promise<{ created: boolean; dropped: boolean; repaired: boolean }>;
96
116
  /**
97
117
  * Ensure the inbound Email Routing rule that delivers the support address to the app worker.
98
118
  * Idempotent, keyed on the rule name. Returns `skipped: true` when no routing config was supplied.
@@ -106,8 +126,8 @@ export interface SupportProvisionResult {
106
126
  bucket: { bucket: string; created: boolean; skipped: boolean };
107
127
  /** The environments a classification worker was deployed for. */
108
128
  environments: ManagedEnvironment[];
109
- /** What the full-text index did, per environment — created, dropped, or already correct. */
110
- search: Array<{ env: ManagedEnvironment; created: boolean; dropped: boolean }>;
129
+ /** What the full-text index did, per environment — created, dropped, repaired, or already correct. */
130
+ search: Array<{ env: ManagedEnvironment; created: boolean; dropped: boolean; repaired: boolean }>;
111
131
  /** Whether the inbound routing rule was created, already present, or skipped. */
112
132
  routing: { created: boolean; skipped: boolean };
113
133
  }
@@ -157,6 +177,11 @@ export interface SupportDeprovisioner {
157
177
  * environment before it removes the bucket or the rule they share (#591).
158
178
  */
159
179
  hasWorker(env: ManagedEnvironment): Promise<boolean>;
180
+ /**
181
+ * Delete the env's classification Workflows, by {@link supportWorkflowNames}. Idempotent (a Workflow already
182
+ * gone is a no-op). Deleting the worker leaves them registered, so they go first (#789).
183
+ */
184
+ deleteWorkflows(env: ManagedEnvironment): Promise<void>;
160
185
  /** Delete the env's classification worker. Idempotent. */
161
186
  deleteWorker(env: ManagedEnvironment): Promise<void>;
162
187
  /**
@@ -200,7 +225,7 @@ export interface SupportDeprovisionResult {
200
225
  * is every environment's support history — so they go with the last environment, never a staging teardown.
201
226
  * 3. Remove the routing rule, when asked, **first**: stop new mail arriving before removing the worker that would
202
227
  * have handled it.
203
- * 4. Delete the environment's worker, then — only when asked — the bucket and everything in it.
228
+ * 4. Delete the environment's Workflows, then its worker, then — only when asked — the bucket and everything in it.
204
229
  */
205
230
  export async function deprovisionSupport(
206
231
  deprovisioner: SupportDeprovisioner,
@@ -213,6 +238,7 @@ export async function deprovisionSupport(
213
238
  ...(options.removeRouting ? [{ what: "the inbound routing rule", flag: "--routing-zone" }] : []),
214
239
  ]);
215
240
  const routingRuleRemoved = options.removeRouting ? (await deprovisioner.removeRoutingRule()).removed : false;
241
+ await deprovisioner.deleteWorkflows(env);
216
242
  await deprovisioner.deleteWorker(env);
217
243
  if (options.deleteStorage) await deprovisioner.deleteBucket();
218
244
  return { env, routingRuleRemoved };
package/src/reply/send.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
4
  import type { AuditEmit } from "@pithy-sh/core/src/audit/recorder";
5
+ import { commandName } from "@pithy-sh/core/src/cli/command";
5
6
  import type { Logger } from "@pithy-sh/core/src/logger/logger";
6
7
  import { SupportAuditActions } from "../audit/actions";
7
8
  import type { SupportConfig } from "../config/config";
@@ -9,7 +10,6 @@ import { SupportMessage } from "../data/message";
9
10
  import { SUPPORT_MESSAGES_TABLE, SUPPORT_THREADS_TABLE, type SupportDatabase } from "../data/tables";
10
11
  import { SupportNotFoundError, SupportReplyFailedError } from "../error/errors";
11
12
  import { buildReferencesHeader, replySubject } from "../mime/threading";
12
- import { indexMessage } from "../store/search";
13
13
 
14
14
  /**
15
15
  * Sending a reply — through `@pithy-sh/email`'s durable send path, never directly.
@@ -84,8 +84,6 @@ export interface ReplyDeps {
84
84
  inReplyTo?: string;
85
85
  references?: string;
86
86
  }) => Promise<{ jobId: string }>;
87
- /** Whether the full-text index is in use. */
88
- fts: boolean;
89
87
  /** The audit seam. */
90
88
  emit: AuditEmit;
91
89
  /** The request logger. */
@@ -213,7 +211,7 @@ export async function sendReply(deps: ReplyDeps, input: ReplyInput): Promise<Rep
213
211
  if (!enqueue) {
214
212
  throw new SupportReplyFailedError({
215
213
  message: "This deployment cannot send mail.",
216
- action: "Add the email capability (`pithy add email`) and provision it, then retry.",
214
+ action: `Compose the email capability into this Worker with \`${commandName("add")}\` and provision it, then retry.`,
217
215
  detail: `thread ${input.threadId} needs a mailed reply and no email capability is composed`,
218
216
  });
219
217
  }
@@ -276,15 +274,6 @@ export async function sendReply(deps: ReplyDeps, input: ReplyInput): Promise<Rep
276
274
  };
277
275
  await deps.db.insertInto(SUPPORT_MESSAGES_TABLE).values(SupportMessage.encode(message)).execute();
278
276
 
279
- if (deps.fts) {
280
- try {
281
- await indexMessage(deps.db, { threadId: input.threadId, messageId, subject, body: input.body });
282
- } catch (error) {
283
- // A search miss, never a lost reply. `reindexThread` is the repair.
284
- deps.log.warn("support reply not indexed", { messageId, error });
285
- }
286
- }
287
-
288
277
  await deps.db
289
278
  .updateTable(SUPPORT_THREADS_TABLE)
290
279
  .set((eb) => ({
@@ -87,16 +87,23 @@ export function searchPredicate(term: string, options: { fts: boolean }): Expres
87
87
  /**
88
88
  * Add a message to the full-text index, replacing any row already there for it.
89
89
  *
90
- * **Remove-then-insert, not a bare insert** — the same shape the CMS `syncFts` uses, and for the
91
- * reason application-managed indexes always need it: an FTS5 table has no primary key and no unique
92
- * constraint to lean on, so a second call for the same message would silently add a second copy and
93
- * the thread would start appearing twice in its own search results. Making the write idempotent means
94
- * a retry, a reindex, and a first index are all the same operation, which is the property every other
95
- * write path in this repo is built on.
90
+ * **No write path calls this any more.** The index is maintained by the triggers `createSearchIndex`
91
+ * provisions (`store/searchIndex.ts`), so a message is indexed because it was written rather than
92
+ * because the code that wrote it remembered. This is the **repair** primitive now: {@link reindexThread}
93
+ * and {@link reindexAll} are its only callers in the kit, and it stays exported because
94
+ * `package.json` exports `./src/*`, so an adopter may be reaching it.
96
95
  *
97
- * Best-effort by contract: a failed index write must never lose a customer's message, so the caller
98
- * logs and carries on — the row is still there, still readable, still repairable by
99
- * {@link reindexThread}, and only missing from one search box.
96
+ * **Remove-then-insert, not a bare insert** — the same shape the trigger bodies use, and for the
97
+ * reason an FTS5 table always needs it: it has no primary key and no unique constraint to lean on, so
98
+ * a second call for the same message would silently add a second copy and the thread would start
99
+ * appearing twice in its own search results. Making the write idempotent means a retry, a reindex, and
100
+ * a first index are all the same operation.
101
+ *
102
+ * **It no longer carries a swallow contract, because it is no longer on a message write.** A failure
103
+ * here is a failed repair, and the caller is a command or a route that can say so. The durability
104
+ * question moved with the index itself: a trigger runs inside the message's own statement, so a
105
+ * failure in its body takes the message write down with it — which is what `searchIndex.ts` documents
106
+ * and what the triggers' own tests hold.
100
107
  */
101
108
  export async function indexMessage(
102
109
  db: SupportDatabase,
@@ -5,8 +5,8 @@ import { type Kysely, sql } from "kysely";
5
5
  import type { SupportDatabase } from "../data/tables";
6
6
 
7
7
  /**
8
- * The FTS5 index's lifecycle — created and dropped by `pithy support provision`, **not by a
9
- * migration**.
8
+ * The FTS5 index's lifecycle — the virtual table **and the three triggers that keep it in step**,
9
+ * created and dropped by `pithy support provision`, **not by a migration**.
10
10
  *
11
11
  * ## Why this is not a migration
12
12
  *
@@ -28,7 +28,39 @@ import type { SupportDatabase } from "../data/tables";
28
28
  * config flag, fixable only by knowing to roll back first. Out of the ledger, there is nothing to
29
29
  * corrupt and toggling either way is just a re-provision.
30
30
  *
31
- * Both statements are idempotent, so provisioning stays safe to re-run — the property every other
31
+ * **The triggers inherit that constraint exactly.** They exist only where the table exists, so they
32
+ * are provisioning and not schema — a `CREATE TRIGGER` composed conditionally on `search.fts` would
33
+ * corrupt the ledger for exactly the same reason the table's own statement did. `migrations.workers.test.ts`
34
+ * keeps proving the migration set carries none.
35
+ *
36
+ * ## Why triggers at all
37
+ *
38
+ * The index used to be written by hand at every message write — three call sites, each best-effort —
39
+ * and correctness depended on every future author remembering. There is no shared message-write
40
+ * helper, so a fourth path that forgot produced a thread nobody could search, with nothing failing,
41
+ * nothing logging and no test catching it.
42
+ *
43
+ * `CREATE TRIGGER` was in an undocumented middle on D1 when that was decided: D1 runs an authorizer
44
+ * that rejects SQLite constructs workerd permits (`fts5vocab` is refused where plain FTS5 succeeds),
45
+ * so a trigger passing under Miniflare proved nothing about the deployment that matters. That is
46
+ * settled — the Leed CMS ships a `BEFORE UPDATE … BEGIN SELECT RAISE(ABORT, …); END` trigger in a
47
+ * production migration, so the authorizer permits both the statement and a body that aborts.
48
+ *
49
+ * **The durability contract inverts, deliberately.** A trigger runs inside the message's own
50
+ * statement, so a failure in its body takes the message write down with it — where the old call sites
51
+ * logged and carried on. That swallow existed because a second round trip to a separate table can
52
+ * fail on its own; inside one statement it cannot. The body is a `DELETE` plus an `INSERT` of values
53
+ * already present in `NEW.*`, with no conversion, no constraint and no foreign key, so what remains is
54
+ * the FTS table being absent — which is what {@link SEARCH_TRIGGERS} cannot outlive, since
55
+ * {@link dropSearchIndex} drops the pair together — or disk and corruption, which would have failed the
56
+ * message insert anyway.
57
+ *
58
+ * The one gap the kit cannot close from here is an adopter who upgrades and deploys without re-running
59
+ * `pithy support provision`: the calls are gone, the triggers were never created, and messages
60
+ * silently stop being indexed. `pithy doctor` reports a provisioned table with no triggers as drift
61
+ * and names that command, per environment.
62
+ *
63
+ * Every statement here is idempotent, so provisioning stays safe to re-run — the property every other
32
64
  * step of `pithy support provision` already has.
33
65
  */
34
66
 
@@ -36,7 +68,128 @@ import type { SupportDatabase } from "../data/tables";
36
68
  export const SEARCH_TABLE = "pithy_support_search";
37
69
 
38
70
  /**
39
- * Create the full-text index if it is not already there.
71
+ * The three triggers, in create order — `_ai`, `_au`, `_ad` after SQLite's own FTS5 naming for the
72
+ * insert, update and delete hooks.
73
+ *
74
+ * Exported because two other places need the exact names and may not re-derive them: `pithy doctor`
75
+ * asks a deployed database whether they are there, and {@link dropSearchIndex} drops them. They carry
76
+ * the `pithy_support_` prefix every table in this capability does, for the same reason — `sqlite_master`
77
+ * is one flat namespace per database and the prefix is the only partition.
78
+ */
79
+ export const SEARCH_TRIGGERS = [
80
+ "pithy_support_search_ai",
81
+ "pithy_support_search_au",
82
+ "pithy_support_search_ad",
83
+ ] as const;
84
+
85
+ /**
86
+ * Every name a provisioner or `pithy doctor` asks `sqlite_master` about — the table and its triggers.
87
+ *
88
+ * One list, read by both, because the two have to agree about what "provisioned" means. A doctor that
89
+ * looked for two triggers while the provisioner created three would report drift nothing could clear.
90
+ */
91
+ export const SEARCH_OBJECTS: readonly string[] = [SEARCH_TABLE, ...SEARCH_TRIGGERS];
92
+
93
+ /** What is actually in a database: the virtual table, and how much of the trigger set. */
94
+ export interface SearchIndexState {
95
+ /** Whether `pithy_support_search` exists. */
96
+ table: boolean;
97
+ /** How many of the three triggers exist — `"some"` is drift, not a pass. */
98
+ triggers: "all" | "some" | "none";
99
+ }
100
+
101
+ /**
102
+ * Read a `sqlite_master` name listing into the two facts anything acts on.
103
+ *
104
+ * **The trigger count is three-valued, and `"some"` is why.** A partial set means one `CREATE TRIGGER`
105
+ * failed or one was dropped by hand, and the consequence is per statement kind — two of three leaves
106
+ * updates or deletes going unindexed while inserts look fine, which is the worst way round. Folding it
107
+ * into `"none"` would also hide it from a teardown: `search.fts: false` has to clear a stray trigger,
108
+ * because a trigger whose table is gone fails every write to `pithy_support_messages`.
109
+ */
110
+ export function searchIndexState(names: readonly string[]): SearchIndexState {
111
+ const found = new Set(names);
112
+ const present = SEARCH_TRIGGERS.filter((name) => found.has(name)).length;
113
+ return {
114
+ table: found.has(SEARCH_TABLE),
115
+ triggers: present === SEARCH_TRIGGERS.length ? "all" : present === 0 ? "none" : "some",
116
+ };
117
+ }
118
+
119
+ /** What a provision run has to do to one environment's index. */
120
+ export type SearchIndexAction =
121
+ /** Already in the state `search.fts` asks for. */
122
+ | "none"
123
+ /** The table is not there: create it, its triggers, and backfill. */
124
+ | "create"
125
+ /** The table is there and its triggers are not: create them, and backfill the gap they left. */
126
+ | "repair"
127
+ /** `search.fts` is off and something is there: drop the triggers and the table. */
128
+ | "drop";
129
+
130
+ /**
131
+ * Decide what `pithy support provision` does to one environment's index.
132
+ *
133
+ * **`repair` is the member this release exists for.** A run used to compare one boolean against one
134
+ * boolean — table present against flag set — and return early when they matched. That is exactly the
135
+ * state an adopter is in after upgrading and deploying: the table is present, the flag is set, the
136
+ * `indexMessage` calls are gone from every write path, and the triggers were never created. The old
137
+ * comparison read that as nothing to do, so the command `pithy doctor` names would not have been the
138
+ * command that fixes it.
139
+ *
140
+ * A repair backfills as a create does, because the gap is real: messages written between the deploy
141
+ * and the re-provision had no trigger to fire for them and no application call either.
142
+ *
143
+ * `drop` fires on triggers alone as well as on the table. A trigger body resolves its table at run
144
+ * time, so a trigger outlives a hand-dropped table — and while it does, every write to
145
+ * `pithy_support_messages` fails. Turning the flag off has to clear that rather than read it as a
146
+ * database with no index.
147
+ */
148
+ export function searchIndexAction(state: SearchIndexState, wanted: boolean): SearchIndexAction {
149
+ if (!wanted) return state.table || state.triggers !== "none" ? "drop" : "none";
150
+ if (!state.table) return "create";
151
+ return state.triggers === "all" ? "none" : "repair";
152
+ }
153
+
154
+ /**
155
+ * The `CREATE TRIGGER` statements, one per {@link SEARCH_TRIGGERS} entry and in that order.
156
+ *
157
+ * **`BEGIN` and `END` are uppercase, and that is load-bearing.** Miniflare accepts a lowercase
158
+ * compound-statement body; remote D1 rejects it as `incomplete input [code: 7500]`, so a lowercased
159
+ * body passes every local test in this repository and fails on deploy. `searchIndex.test.ts` fails on
160
+ * the lowercase form, because no Workers test can.
161
+ *
162
+ * **Every column is the physical snake_case name.** `CamelCasePlugin` snake-cases the identifiers the
163
+ * query builder emits and never touches a string inside a `sql` template, so this is the one place in
164
+ * the package that has to spell them itself: `NEW.id`, `NEW.thread_id`, `NEW.subject`, `NEW.text_body`
165
+ * — never the camelCase keys `data/tables.ts` declares.
166
+ *
167
+ * **Insert and update both delete first.** An FTS5 table has no primary key and no unique constraint
168
+ * to lean on, so a bare insert on a second write for the same message would add a second copy and the
169
+ * thread would start appearing twice in its own search results — the same reason `indexMessage` is
170
+ * remove-then-insert. The update body keys its delete on `OLD.id`, so a row whose id changed takes its
171
+ * old index entry with it.
172
+ */
173
+ export function searchTriggerStatements(): string[] {
174
+ const insert = "INSERT INTO pithy_support_search(thread_id, message_id, subject, body)";
175
+ const values = "VALUES (NEW.thread_id, NEW.id, NEW.subject, NEW.text_body)";
176
+ return [
177
+ `CREATE TRIGGER ${SEARCH_TRIGGERS[0]} AFTER INSERT ON pithy_support_messages BEGIN
178
+ DELETE FROM pithy_support_search WHERE message_id = NEW.id;
179
+ ${insert} ${values};
180
+ END`,
181
+ `CREATE TRIGGER ${SEARCH_TRIGGERS[1]} AFTER UPDATE ON pithy_support_messages BEGIN
182
+ DELETE FROM pithy_support_search WHERE message_id = OLD.id;
183
+ ${insert} ${values};
184
+ END`,
185
+ `CREATE TRIGGER ${SEARCH_TRIGGERS[2]} AFTER DELETE ON pithy_support_messages BEGIN
186
+ DELETE FROM pithy_support_search WHERE message_id = OLD.id;
187
+ END`,
188
+ ];
189
+ }
190
+
191
+ /**
192
+ * Create the full-text index and its triggers if they are not already there.
40
193
  *
41
194
  * A standalone (not external-content) FTS5 table. External content would store the text once rather
42
195
  * than twice, but it requires the indexed column names to exist verbatim on the content table, and
@@ -46,6 +199,13 @@ export const SEARCH_TABLE = "pithy_support_search";
46
199
  * `thread_id` and `message_id` are UNINDEXED: they are carried so a match resolves straight to a
47
200
  * conversation and so one message's rows can be replaced, and tokenizing a UUID would only add noise
48
201
  * to every query.
202
+ *
203
+ * **The triggers are dropped and recreated rather than guarded with `IF NOT EXISTS`**, which is what
204
+ * makes re-provisioning after a kit upgrade actually converge. `IF NOT EXISTS` is idempotent in the
205
+ * weak sense — it leaves a trigger whose body this release has changed exactly as it was, so a fixed
206
+ * body would reach nobody who already had the old one, and the command `pithy doctor` names would not
207
+ * be the command that fixes it. The pair is ordered so the table is always there before a body refers
208
+ * to it.
49
209
  */
50
210
  export async function createSearchIndex(db: SupportDatabase | Kysely<unknown>): Promise<void> {
51
211
  await sql`
@@ -57,15 +217,26 @@ export async function createSearchIndex(db: SupportDatabase | Kysely<unknown>):
57
217
  tokenize = 'unicode61 remove_diacritics 2'
58
218
  )
59
219
  `.execute(db as Kysely<unknown>);
220
+ for (const [index, statement] of searchTriggerStatements().entries()) {
221
+ await sql.raw(`drop trigger if exists ${SEARCH_TRIGGERS[index]}`).execute(db as Kysely<unknown>);
222
+ await sql.raw(statement).execute(db as Kysely<unknown>);
223
+ }
60
224
  }
61
225
 
62
226
  /**
63
- * Drop the full-text index if it is there.
227
+ * Drop the full-text index and its triggers if they are there.
64
228
  *
65
229
  * Safe by construction: the index is derived, so this costs a rebuild rather than a restore. It is
66
230
  * what `pithy support provision` runs when `search.fts` goes back to false — and the reason turning
67
231
  * the feature off is now a one-command operation instead of a migration rollback.
232
+ *
233
+ * **The triggers go first, and the order is the whole point.** A trigger whose body inserts into a
234
+ * table that is gone fails every write to `pithy_support_messages`, so dropping the table out from
235
+ * under them would turn a feature flag into an outage on the capability's main write path.
68
236
  */
69
237
  export async function dropSearchIndex(db: SupportDatabase | Kysely<unknown>): Promise<void> {
238
+ for (const trigger of SEARCH_TRIGGERS) {
239
+ await sql.raw(`drop trigger if exists ${trigger}`).execute(db as Kysely<unknown>);
240
+ }
70
241
  await sql`drop table if exists pithy_support_search`.execute(db as Kysely<unknown>);
71
242
  }