@pithy-sh/support 0.1.0

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 (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/package.json +68 -0
  4. package/pithy.manifest.json +40 -0
  5. package/src/ai/classify.ts +239 -0
  6. package/src/attachment/store.ts +78 -0
  7. package/src/audit/actions.ts +71 -0
  8. package/src/capability.ts +293 -0
  9. package/src/client/projection.ts +60 -0
  10. package/src/cloudflare-test.d.ts +15 -0
  11. package/src/config/config.ts +400 -0
  12. package/src/data/attachment.ts +65 -0
  13. package/src/data/billingScope.ts +32 -0
  14. package/src/data/categories.ts +117 -0
  15. package/src/data/classification.ts +50 -0
  16. package/src/data/enums.ts +90 -0
  17. package/src/data/flag.ts +37 -0
  18. package/src/data/message.ts +224 -0
  19. package/src/data/tables.ts +58 -0
  20. package/src/data/thread.ts +138 -0
  21. package/src/error/errors.ts +133 -0
  22. package/src/http/guards.ts +59 -0
  23. package/src/http/handlers.ts +418 -0
  24. package/src/http/resolve.ts +109 -0
  25. package/src/http/responses.ts +506 -0
  26. package/src/http/routes.ts +272 -0
  27. package/src/http/schemas.ts +251 -0
  28. package/src/http/scopes.ts +117 -0
  29. package/src/http/views.ts +169 -0
  30. package/src/inbound/authenticity.ts +114 -0
  31. package/src/inbound/guard.ts +127 -0
  32. package/src/inbound/handler.ts +102 -0
  33. package/src/inbound/ingest.ts +548 -0
  34. package/src/inbound/recipient.ts +67 -0
  35. package/src/index.ts +63 -0
  36. package/src/link/sender.ts +334 -0
  37. package/src/migrations/0001_threads.ts +296 -0
  38. package/src/mime/address.ts +37 -0
  39. package/src/mime/parse.ts +299 -0
  40. package/src/mime/sanitize.ts +253 -0
  41. package/src/mime/threading.ts +127 -0
  42. package/src/mime/truncate.ts +55 -0
  43. package/src/provision/provisionSupport.ts +179 -0
  44. package/src/provision/resolveSupportConfig.ts +67 -0
  45. package/src/reply/send.ts +322 -0
  46. package/src/reply/snippets.ts +167 -0
  47. package/src/secret/registry.ts +24 -0
  48. package/src/seeds/example.ts +385 -0
  49. package/src/store/paging.ts +22 -0
  50. package/src/store/search.ts +197 -0
  51. package/src/store/searchIndex.ts +71 -0
  52. package/src/store/threads.ts +452 -0
  53. package/src/submission/encoding.ts +66 -0
  54. package/src/submission/guard.ts +120 -0
  55. package/src/submission/submit.ts +539 -0
  56. package/src/version.generated.ts +16 -0
  57. package/src/workflows/classify.ts +164 -0
  58. package/src/workflows/retryPolicy.ts +48 -0
  59. package/src/workflows/specs.ts +61 -0
  60. package/src/workflows/worker.ts +82 -0
  61. package/src/workflows/wrangler.jsonc +46 -0
@@ -0,0 +1,385 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { EXAMPLE_ADA, EXAMPLE_ALAN, EXAMPLE_GRACE } from "@pithy-sh/core/src/seed/exampleIdentities";
5
+ import { d1SeedGroup, defineSeed, type SeedSet } from "@pithy-sh/core/src/seed/seed";
6
+ import { SupportClassification } from "../data/classification";
7
+ import { SupportMessage } from "../data/message";
8
+ import { SUPPORT_CLASSIFICATIONS_TABLE, SUPPORT_MESSAGES_TABLE, SUPPORT_THREADS_TABLE } from "../data/tables";
9
+ import { SupportThread } from "../data/thread";
10
+
11
+ /**
12
+ * Four conversations for the canonical example cast, spread across the shapes a dashboard has to
13
+ * render.
14
+ *
15
+ * **Ada** is angry about a double charge — `billing`, `urgent`, and she is the one with a live Apple
16
+ * subscription in the payments seed, so a thread view opened on her shows the linkage working end to
17
+ * end rather than an empty panel. **Grace** cannot sign in — `account_access`, `normal`, two
18
+ * messages, so the thread view has an actual conversation in it and the reply path has something
19
+ * threaded to attach to. **Alan** sent a feature request that has been dealt with — `feature_request`,
20
+ * `low`, `positive`, and **archived**, so the done pile is not empty on a fresh backend and the
21
+ * archived filter is demonstrably doing something.
22
+ *
23
+ * **Grace again, from inside the app** — a `bug_report` filed through `POST /support/feedback`, so the
24
+ * fixture carries both channels and the same person on each. That pairing is the demonstration: her
25
+ * mail thread is linked to her account by an address in a header nobody proved, and her app thread is
26
+ * linked by a session, and a console that renders the two identically has a bug this seed will show
27
+ * you. It is also the only row carrying submission context, which is what a bug report knows and a
28
+ * piece of mail cannot — and the only row carrying a `declaredCategory`, because it is the only sender
29
+ * who was offered a chooser.
30
+ *
31
+ * **And it deliberately disagrees with itself.** Grace filed it under "something is missing" and the
32
+ * model read the text and called it a bug, so `declaredCategory` is `feature_request` while `category`
33
+ * is `bug_report`. That is the ordinary case rather than a contrived one — people file "X does not
34
+ * work for me" under a missing feature constantly — and it is the row that makes the rendering mistake
35
+ * visible before it ships: a console that shows one word, or shows the wrong one, has a bug this seed
36
+ * will hand you. Every mail thread's is null, because nobody asked its sender anything.
37
+ *
38
+ * That is one row per priority, all four sentiments, four categories, both channels, both link
39
+ * provenances, both a stated and an unstated category, and both sides of `archived` — the states
40
+ * anything reading these tables has to handle, without anybody authoring a fixture first.
41
+ *
42
+ * **Everything is fixed — the ids and the clock.** `pithy seed` is `INSERT OR IGNORE`, so a generated
43
+ * UUID would give Ada a second complaint on every run.
44
+ *
45
+ * **No attachments, and no raw messages.** Both would mean seeding R2 objects the D1 rows point at,
46
+ * and a fixture whose rows name bytes that are not there is worse than one that claims less. The
47
+ * classifications are seeded because they are the interesting half: a thread carries a model id, so
48
+ * the seed shows what "which rows came from which model" looks like before anybody has run one.
49
+ *
50
+ * Composed in only when the project turns on `seed.includeExamples`, and only for `dev` and
51
+ * `staging` — an example fixture never targets prod, whatever that setting says.
52
+ */
53
+
54
+ /** Where this set sorts. After auth's users (100) and payments' purchases (250), which it links to. */
55
+ const SUPPORT_EXAMPLE_SEED_ORDER = 260;
56
+
57
+ /** Fixed ids, so the fixture is idempotent and a classification cannot drift off its thread. */
58
+ const BILLING_THREAD_ID = "a1b2c3d4-0001-4a11-9c01-0e1f2a3b4c50";
59
+ const ACCESS_THREAD_ID = "a1b2c3d4-0002-4a11-9c01-0e1f2a3b4c51";
60
+ const FEATURE_THREAD_ID = "a1b2c3d4-0003-4a11-9c01-0e1f2a3b4c52";
61
+ const APP_THREAD_ID = "a1b2c3d4-0004-4a11-9c01-0e1f2a3b4c53";
62
+
63
+ const BILLING_MESSAGE_ID = "b1b2c3d4-0001-4a11-9c01-0e1f2a3b4c60";
64
+ const ACCESS_MESSAGE_ID = "b1b2c3d4-0002-4a11-9c01-0e1f2a3b4c61";
65
+ const ACCESS_REPLY_ID = "b1b2c3d4-0003-4a11-9c01-0e1f2a3b4c62";
66
+ const FEATURE_MESSAGE_ID = "b1b2c3d4-0004-4a11-9c01-0e1f2a3b4c63";
67
+ const APP_MESSAGE_ID = "b1b2c3d4-0005-4a11-9c01-0e1f2a3b4c64";
68
+
69
+ const BILLING_CLASSIFICATION_ID = "c1b2c3d4-0001-4a11-9c01-0e1f2a3b4c70";
70
+ const ACCESS_CLASSIFICATION_ID = "c1b2c3d4-0002-4a11-9c01-0e1f2a3b4c71";
71
+ const FEATURE_CLASSIFICATION_ID = "c1b2c3d4-0003-4a11-9c01-0e1f2a3b4c72";
72
+ const APP_CLASSIFICATION_ID = "c1b2c3d4-0004-4a11-9c01-0e1f2a3b4c73";
73
+
74
+ /** The inbox these threads arrived on — a subdomain, which is the only safe shape for a real one. */
75
+ const INBOX = "support@help.example.com";
76
+
77
+ /** The model the seeded classifications claim to have come from. */
78
+ const MODEL = "@cf/meta/llama-3.1-8b-instruct";
79
+
80
+ /** One fixed moment. The demo is about state, not about a calendar. */
81
+ const AT = new Date("2026-01-10T09:00:00.000Z");
82
+ const LATER = new Date("2026-01-10T11:30:00.000Z");
83
+ const ARCHIVED_AT = new Date("2026-01-11T16:00:00.000Z");
84
+
85
+ export const supportExampleSeed: SeedSet = defineSeed({
86
+ name: "example",
87
+ order: SUPPORT_EXAMPLE_SEED_ORDER,
88
+ environments: ["dev", "staging"],
89
+ example: true,
90
+ d1: [
91
+ d1SeedGroup("app", SUPPORT_THREADS_TABLE, SupportThread, [
92
+ {
93
+ id: BILLING_THREAD_ID,
94
+ channel: "email",
95
+ inboxAddress: INBOX,
96
+ subject: "I was charged twice this month",
97
+ fromAddress: EXAMPLE_ADA.email,
98
+ fromName: EXAMPLE_ADA.name,
99
+ // Authenticated, which is what lets the seeded thread show a real customer link.
100
+ senderAuthenticated: true,
101
+ userId: EXAMPLE_ADA.id,
102
+ accountLinkSource: "email_address",
103
+ category: "billing",
104
+ priority: "urgent",
105
+ sentiment: "angry",
106
+ confidence: 0.94,
107
+ model: MODEL,
108
+ classifiedAt: AT,
109
+ archived: false,
110
+ archivedAt: null,
111
+ archivedBy: null,
112
+ messageCount: 1,
113
+ firstMessageAt: AT,
114
+ lastMessageAt: AT,
115
+ createdAt: AT,
116
+ updatedAt: AT,
117
+ },
118
+ {
119
+ id: ACCESS_THREAD_ID,
120
+ channel: "email",
121
+ inboxAddress: INBOX,
122
+ subject: "Magic link never arrives",
123
+ fromAddress: EXAMPLE_GRACE.email,
124
+ fromName: EXAMPLE_GRACE.name,
125
+ senderAuthenticated: true,
126
+ userId: EXAMPLE_GRACE.id,
127
+ accountLinkSource: "email_address",
128
+ category: "account_access",
129
+ priority: "normal",
130
+ sentiment: "frustrated",
131
+ confidence: 0.81,
132
+ model: MODEL,
133
+ classifiedAt: AT,
134
+ archived: false,
135
+ archivedAt: null,
136
+ archivedBy: null,
137
+ // Two: her message and the answer, so a thread view has a conversation in it.
138
+ messageCount: 2,
139
+ firstMessageAt: AT,
140
+ lastMessageAt: LATER,
141
+ createdAt: AT,
142
+ updatedAt: LATER,
143
+ },
144
+ {
145
+ id: FEATURE_THREAD_ID,
146
+ channel: "email",
147
+ inboxAddress: INBOX,
148
+ subject: "Could you add CSV export?",
149
+ fromAddress: EXAMPLE_ALAN.email,
150
+ fromName: EXAMPLE_ALAN.name,
151
+ senderAuthenticated: true,
152
+ userId: EXAMPLE_ALAN.id,
153
+ accountLinkSource: "email_address",
154
+ category: "feature_request",
155
+ priority: "low",
156
+ sentiment: "positive",
157
+ confidence: 0.88,
158
+ model: MODEL,
159
+ classifiedAt: AT,
160
+ // Done. The archived filter needs something to find, and the done pile needs to not be empty
161
+ // on a fresh backend.
162
+ archived: true,
163
+ archivedAt: ARCHIVED_AT,
164
+ archivedBy: "example-dashboard",
165
+ messageCount: 1,
166
+ firstMessageAt: AT,
167
+ lastMessageAt: AT,
168
+ createdAt: AT,
169
+ updatedAt: ARCHIVED_AT,
170
+ },
171
+ {
172
+ id: APP_THREAD_ID,
173
+ // The other channel. Grace already has a mail thread above, and having the same person on both
174
+ // is the point: a console that cannot tell her session-proven link from her header-inferred one
175
+ // is rendering two very different pieces of evidence as one fact.
176
+ channel: "app",
177
+ // Populated even on an app thread: it is the address a reply will come back to, which is the
178
+ // only meaning `inboxAddress` has here.
179
+ inboxAddress: INBOX,
180
+ subject: "Export button does nothing on the reports screen",
181
+ // Her account's own address, read from the account rather than from anything a client sent.
182
+ fromAddress: EXAMPLE_GRACE.email,
183
+ fromName: EXAMPLE_GRACE.name,
184
+ // True for the strongest reason available: there was no header to prove, because the session
185
+ // was proved before the request reached a handler.
186
+ senderAuthenticated: true,
187
+ userId: EXAMPLE_GRACE.id,
188
+ accountLinkSource: "session",
189
+ // What she said, beside what the model made of it. She reached for "something is missing"; the
190
+ // classifier read the text and called it a bug. Both are true, they disagree, and neither may
191
+ // overwrite the other — which is the entire reason these are two columns.
192
+ declaredCategory: "feature_request",
193
+ category: "bug_report",
194
+ priority: "normal",
195
+ sentiment: "neutral",
196
+ confidence: 0.91,
197
+ model: MODEL,
198
+ classifiedAt: AT,
199
+ archived: false,
200
+ archivedAt: null,
201
+ archivedBy: null,
202
+ messageCount: 1,
203
+ firstMessageAt: LATER,
204
+ lastMessageAt: LATER,
205
+ createdAt: LATER,
206
+ updatedAt: LATER,
207
+ },
208
+ ]),
209
+ d1SeedGroup("app", SUPPORT_MESSAGES_TABLE, SupportMessage, [
210
+ {
211
+ id: BILLING_MESSAGE_ID,
212
+ threadId: BILLING_THREAD_ID,
213
+ direction: "inbound",
214
+ channel: "email",
215
+ submittedByUserId: null,
216
+ context: null,
217
+ mimeMessageId: "seed-billing-1@example.com",
218
+ mimeInReplyTo: null,
219
+ mimeReferences: null,
220
+ fromAddress: EXAMPLE_ADA.email,
221
+ fromName: EXAMPLE_ADA.name,
222
+ toAddress: INBOX,
223
+ subject: "I was charged twice this month",
224
+ textBody:
225
+ "My card shows two charges for the same subscription on the 3rd. I have only ever had one account. Please refund the duplicate.",
226
+ htmlBody: null,
227
+ emailJobId: null,
228
+ rawKey: null,
229
+ rawBytes: null,
230
+ receivedAt: AT,
231
+ createdAt: AT,
232
+ },
233
+ {
234
+ id: ACCESS_MESSAGE_ID,
235
+ threadId: ACCESS_THREAD_ID,
236
+ direction: "inbound",
237
+ channel: "email",
238
+ submittedByUserId: null,
239
+ context: null,
240
+ mimeMessageId: "seed-access-1@example.com",
241
+ mimeInReplyTo: null,
242
+ mimeReferences: null,
243
+ fromAddress: EXAMPLE_GRACE.email,
244
+ fromName: EXAMPLE_GRACE.name,
245
+ toAddress: INBOX,
246
+ subject: "Magic link never arrives",
247
+ textBody:
248
+ "I have asked for a sign-in link four times today and none of them have arrived. I checked spam. Is something broken?",
249
+ htmlBody: null,
250
+ emailJobId: null,
251
+ rawKey: null,
252
+ rawBytes: null,
253
+ receivedAt: AT,
254
+ createdAt: AT,
255
+ },
256
+ {
257
+ id: ACCESS_REPLY_ID,
258
+ threadId: ACCESS_THREAD_ID,
259
+ direction: "outbound",
260
+ // How this answer was delivered: it went out through the durable send path, so it carries the
261
+ // job id below. An answer stored for the app instead would be `app` and carry none.
262
+ channel: "email",
263
+ submittedByUserId: null,
264
+ context: null,
265
+ // Null, exactly as the real reply path leaves it: Cloudflare assigns the sent `Message-ID`
266
+ // and never tells the enqueuer, so a seeded value here would model something that cannot
267
+ // happen.
268
+ mimeMessageId: null,
269
+ mimeInReplyTo: "seed-access-1@example.com",
270
+ mimeReferences: ["seed-access-1@example.com"],
271
+ fromAddress: INBOX,
272
+ fromName: null,
273
+ toAddress: EXAMPLE_GRACE.email,
274
+ subject: "Re: Magic link never arrives",
275
+ textBody:
276
+ "Hi Grace,\n\nI found the problem — your address had bounced earlier in the week and was on our suppression list. I have cleared it. Try again and it should arrive within a minute.\n\nSorry for the trouble.\n",
277
+ htmlBody: null,
278
+ emailJobId: "seed-email-job-access-1",
279
+ rawKey: null,
280
+ rawBytes: null,
281
+ receivedAt: LATER,
282
+ createdAt: LATER,
283
+ },
284
+ {
285
+ id: FEATURE_MESSAGE_ID,
286
+ threadId: FEATURE_THREAD_ID,
287
+ direction: "inbound",
288
+ channel: "email",
289
+ submittedByUserId: null,
290
+ context: null,
291
+ mimeMessageId: "seed-feature-1@example.com",
292
+ mimeInReplyTo: null,
293
+ mimeReferences: null,
294
+ fromAddress: EXAMPLE_ALAN.email,
295
+ fromName: EXAMPLE_ALAN.name,
296
+ toAddress: INBOX,
297
+ subject: "Could you add CSV export?",
298
+ textBody:
299
+ "Loving the product. The one thing I miss is being able to export my data as CSV for a spreadsheet. No rush.",
300
+ htmlBody: null,
301
+ emailJobId: null,
302
+ rawKey: null,
303
+ rawBytes: null,
304
+ receivedAt: AT,
305
+ createdAt: AT,
306
+ },
307
+ {
308
+ id: APP_MESSAGE_ID,
309
+ threadId: APP_THREAD_ID,
310
+ direction: "inbound",
311
+ channel: "app",
312
+ // The session's own user id — the identity, not a match on an address.
313
+ submittedByUserId: EXAMPLE_GRACE.id,
314
+ // The only row here with context, because it is the only one whose sender had an app to ask.
315
+ // This is what turns "the button does nothing" into a place to look.
316
+ context: { screen: "reports", appVersion: "2.4.1", platform: "web", environment: "prod", locale: "en-GB" },
317
+ // Server-minted, so a reply carries an `In-Reply-To` her mail client will echo back — without
318
+ // it her answer opens a second thread and the conversation fragments.
319
+ mimeMessageId: `${APP_MESSAGE_ID}@help.example.com`,
320
+ mimeInReplyTo: null,
321
+ mimeReferences: null,
322
+ fromAddress: EXAMPLE_GRACE.email,
323
+ fromName: EXAMPLE_GRACE.name,
324
+ toAddress: INBOX,
325
+ subject: "Export button does nothing on the reports screen",
326
+ textBody:
327
+ "I press Export on the reports screen and nothing happens. No download, no error. It worked last week. Chrome, if that helps.",
328
+ htmlBody: null,
329
+ emailJobId: null,
330
+ rawKey: null,
331
+ rawBytes: null,
332
+ receivedAt: LATER,
333
+ createdAt: LATER,
334
+ },
335
+ ]),
336
+ d1SeedGroup("app", SUPPORT_CLASSIFICATIONS_TABLE, SupportClassification, [
337
+ {
338
+ id: BILLING_CLASSIFICATION_ID,
339
+ threadId: BILLING_THREAD_ID,
340
+ messageId: BILLING_MESSAGE_ID,
341
+ category: "billing",
342
+ priority: "urgent",
343
+ sentiment: "angry",
344
+ confidence: 0.94,
345
+ model: MODEL,
346
+ createdAt: AT,
347
+ },
348
+ {
349
+ id: ACCESS_CLASSIFICATION_ID,
350
+ threadId: ACCESS_THREAD_ID,
351
+ messageId: ACCESS_MESSAGE_ID,
352
+ category: "account_access",
353
+ priority: "normal",
354
+ sentiment: "frustrated",
355
+ confidence: 0.81,
356
+ model: MODEL,
357
+ createdAt: AT,
358
+ },
359
+ {
360
+ id: APP_CLASSIFICATION_ID,
361
+ threadId: APP_THREAD_ID,
362
+ messageId: APP_MESSAGE_ID,
363
+ // The same classifier, over the same federated taxonomy. An app submission introduces no
364
+ // vocabulary of its own — that is what makes this a channel rather than a second capability.
365
+ category: "bug_report",
366
+ priority: "normal",
367
+ sentiment: "neutral",
368
+ confidence: 0.91,
369
+ model: MODEL,
370
+ createdAt: LATER,
371
+ },
372
+ {
373
+ id: FEATURE_CLASSIFICATION_ID,
374
+ threadId: FEATURE_THREAD_ID,
375
+ messageId: FEATURE_MESSAGE_ID,
376
+ category: "feature_request",
377
+ priority: "low",
378
+ sentiment: "positive",
379
+ confidence: 0.88,
380
+ model: MODEL,
381
+ createdAt: AT,
382
+ },
383
+ ]),
384
+ ],
385
+ });
@@ -0,0 +1,22 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The inbox page bounds, in the one module that has no reason to import anything (#430).
6
+ *
7
+ * `http/schemas.ts` bounds a caller's `limit` with the same number the reader clamps to, and it used to
8
+ * read it out of `store/threads.ts` — the Kysely reader, which brings `kysely`, `kysely-d1` and
9
+ * `@cloudflare/workers-types` with it. A request schema is a client's business: a management client
10
+ * building a call must be able to compile the shape it may send, in a browser, with no Worker types in
11
+ * reach. So the two numbers moved and the reader kept the query.
12
+ *
13
+ * **Both moved, not only the one the schema needs.** They are read together — a default clamped into a
14
+ * maximum, on one line, in two queries — and a pair split across two modules is how the next person
15
+ * picks the wrong one.
16
+ */
17
+
18
+ /** How many threads a page holds by default. */
19
+ export const DEFAULT_PAGE_SIZE = 25;
20
+
21
+ /** The most a caller may ask for in one page. */
22
+ export const MAX_PAGE_SIZE = 100;
@@ -0,0 +1,197 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { type Expression, type SqlBool, sql } from "kysely";
5
+ import { SUPPORT_MESSAGES_TABLE, type SupportDatabase } from "../data/tables";
6
+
7
+ /**
8
+ * Text search over the inbox — two backends behind one predicate.
9
+ *
10
+ * **`LIKE` is the default and FTS5 is the opt-in**, which is the opposite of what it looks like it
11
+ * should be. The reason is in `store/searchIndex.ts`: an FTS5 virtual table anywhere in a D1
12
+ * database makes `wrangler d1 export` refuse to dump *the whole database*, so turning it on by
13
+ * default would quietly cost every adopter their backup story for tables that have nothing to do
14
+ * with support. `LIKE` is an unindexed scan and is completely adequate for an inbox measured in
15
+ * thousands of messages, which is what this capability is for.
16
+ *
17
+ * Both paths return a **predicate over `pithy_support_threads`**, not a list of ids, so search
18
+ * composes with the category/archived filters and — crucially — with cursor pagination. Returning
19
+ * ids would mean either capping the match set (silent truncation, which reads as "that is all there
20
+ * is") or paginating twice over.
21
+ *
22
+ * Either way this is keyword search, deliberately. People search a support inbox for a word they
23
+ * remember — an order number, an error string, a surname — not for a concept. `@pithy-sh/vector` is
24
+ * there for anyone who wants meaning-matching, and it is the nicer demo and the wrong default: it
25
+ * cannot find `ORD-40912`.
26
+ */
27
+
28
+ /** The longest search term accepted. Bounded because it becomes a scan predicate. */
29
+ const MAX_TERM = 200;
30
+
31
+ /** Escape the `LIKE` wildcards so a term containing `%` matches a literal `%`. */
32
+ function escapeLike(term: string): string {
33
+ return term.replace(/[\\%_]/g, (char) => `\\${char}`);
34
+ }
35
+
36
+ /**
37
+ * Turn a user's words into an FTS5 MATCH expression.
38
+ *
39
+ * Every token is quoted and the whole thing is ANDed. Quoting is what stops FTS5's own query syntax
40
+ * from leaking through: a bare `NOT`, `OR`, `*` or `(` is an operator to FTS5, so a customer whose
41
+ * surname is a keyword — or anyone deliberately probing — would otherwise change the shape of the
42
+ * query rather than search for a word. This is the same "input is data, never syntax" rule the rest
43
+ * of the package applies to model output and to HTML.
44
+ */
45
+ export function ftsQuery(term: string): string {
46
+ const tokens = term
47
+ .slice(0, MAX_TERM)
48
+ // FTS5 tokenizes on non-alphanumerics anyway, so anything else is punctuation we would only be
49
+ // handing to its parser.
50
+ .split(/[^\p{L}\p{N}_-]+/u)
51
+ .filter((token) => token.length > 0)
52
+ .slice(0, 16);
53
+ return tokens.map((token) => `"${token.replace(/"/g, '""')}"`).join(" AND ");
54
+ }
55
+
56
+ /** Whether a term has anything searchable left after tokenizing. */
57
+ export function isSearchable(term: string, fts: boolean): boolean {
58
+ return fts ? ftsQuery(term).length > 0 : term.trim().length > 0;
59
+ }
60
+
61
+ /**
62
+ * A predicate matching threads whose messages contain `term`.
63
+ *
64
+ * Correlated on the thread id so it drops into a `where` beside every other filter. The subquery is
65
+ * `exists`, so it stops at the first matching message rather than counting them.
66
+ */
67
+ export function searchPredicate(term: string, options: { fts: boolean }): Expression<SqlBool> {
68
+ if (options.fts) {
69
+ // `<table> MATCH ?` naming the table in full is the documented FTS5 form, and the only one that
70
+ // works: the hidden match column keeps the table's own name, so aliasing the table to `s` and
71
+ // writing `s match ?` is `no such column: s`. `thread_id` is UNINDEXED, so the correlation is a
72
+ // filter over the match set rather than part of the match.
73
+ return sql<SqlBool>`exists (
74
+ select 1 from pithy_support_search
75
+ where pithy_support_search.thread_id = pithy_support_threads.id
76
+ and pithy_support_search match ${ftsQuery(term)}
77
+ )`;
78
+ }
79
+ const pattern = `%${escapeLike(term.slice(0, MAX_TERM))}%`;
80
+ return sql<SqlBool>`exists (
81
+ select 1 from pithy_support_messages m
82
+ where m.thread_id = pithy_support_threads.id
83
+ and (m.subject like ${pattern} escape '\\' or m.text_body like ${pattern} escape '\\')
84
+ )`;
85
+ }
86
+
87
+ /**
88
+ * Add a message to the full-text index, replacing any row already there for it.
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.
96
+ *
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.
100
+ */
101
+ export async function indexMessage(
102
+ db: SupportDatabase,
103
+ entry: { threadId: string; messageId: string; subject: string; body: string },
104
+ ): Promise<void> {
105
+ await sql`delete from pithy_support_search where message_id = ${entry.messageId}`.execute(db);
106
+ await sql`
107
+ insert into pithy_support_search(thread_id, message_id, subject, body)
108
+ values (${entry.threadId}, ${entry.messageId}, ${entry.subject}, ${entry.body})
109
+ `.execute(db);
110
+ }
111
+
112
+ /**
113
+ * Rebuild the index for one thread — delete its rows, then re-add every message.
114
+ *
115
+ * The repair path for the failure mode application-side indexing has and triggers would not: a
116
+ * message stored while the index write failed. Also what a reindex after a tokenizer change runs.
117
+ */
118
+ export async function reindexThread(db: SupportDatabase, threadId: string): Promise<number> {
119
+ // Clears by thread rather than relying on `indexMessage`'s per-message delete, so a row whose
120
+ // message has since been removed goes too — that one is unreachable by any per-message call.
121
+ await sql`delete from pithy_support_search where thread_id = ${threadId}`.execute(db);
122
+ const messages = await db
123
+ .selectFrom(SUPPORT_MESSAGES_TABLE)
124
+ .select(["id", "threadId", "subject", "textBody"])
125
+ .where("threadId", "=", threadId)
126
+ .execute();
127
+ for (const message of messages) {
128
+ await indexMessage(db, {
129
+ threadId: message.threadId,
130
+ messageId: message.id,
131
+ subject: message.subject,
132
+ body: message.textBody,
133
+ });
134
+ }
135
+ return messages.length;
136
+ }
137
+
138
+ /**
139
+ * Rebuild the whole index from the messages table, in pages.
140
+ *
141
+ * **Creating the index is not the same as populating it**, and that gap is the one failure a search
142
+ * box must not have: an adopter who turns `search.fts` on after mail already exists gets an empty
143
+ * virtual table, and because the table now *exists* the `LIKE` fallback never fires — so the inbox
144
+ * answers "no matches" for a term that is demonstrably in the body. Silently returning nothing is
145
+ * worse than returning slowly, so provisioning backfills immediately after it creates.
146
+ *
147
+ * Paged on `(receivedAt, id)` rather than an offset, for the same reason the inbox is: rows arriving
148
+ * during a long backfill would shift an offset underneath it and skip messages.
149
+ */
150
+ export async function reindexAll(
151
+ db: SupportDatabase,
152
+ options: { pageSize?: number; onProgress?: (indexed: number) => void } = {},
153
+ ): Promise<number> {
154
+ const pageSize = options.pageSize ?? 200;
155
+ // Cheaper and more obviously correct than deleting per message as we go: the index is derived, so
156
+ // emptying it costs nothing that is not about to be rewritten.
157
+ await sql`delete from pithy_support_search`.execute(db);
158
+
159
+ let cursor: { receivedAt: number; id: string } | undefined;
160
+ let indexed = 0;
161
+
162
+ for (;;) {
163
+ let page = db
164
+ .selectFrom(SUPPORT_MESSAGES_TABLE)
165
+ .select(["id", "threadId", "subject", "textBody", "receivedAt"])
166
+ .orderBy("receivedAt", "asc")
167
+ .orderBy("id", "asc")
168
+ .limit(pageSize);
169
+ if (cursor) {
170
+ const at = cursor.receivedAt;
171
+ const id = cursor.id;
172
+ page = page.where((eb) =>
173
+ eb.or([eb("receivedAt", ">", at), eb.and([eb("receivedAt", "=", at), eb("id", ">", id)])]),
174
+ );
175
+ }
176
+
177
+ const rows = await page.execute();
178
+ if (rows.length === 0) break;
179
+
180
+ for (const row of rows) {
181
+ await indexMessage(db, {
182
+ threadId: row.threadId,
183
+ messageId: row.id,
184
+ subject: row.subject,
185
+ body: row.textBody,
186
+ });
187
+ indexed += 1;
188
+ }
189
+ options.onProgress?.(indexed);
190
+
191
+ const last = rows[rows.length - 1];
192
+ if (!last || rows.length < pageSize) break;
193
+ cursor = { receivedAt: Number(last.receivedAt), id: last.id };
194
+ }
195
+
196
+ return indexed;
197
+ }
@@ -0,0 +1,71 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { type Kysely, sql } from "kysely";
5
+ import type { SupportDatabase } from "../data/tables";
6
+
7
+ /**
8
+ * The FTS5 index's lifecycle — created and dropped by `pithy support provision`, **not by a
9
+ * migration**.
10
+ *
11
+ * ## Why this is not a migration
12
+ *
13
+ * Because it holds no data. Every row in it is derived from `pithy_support_messages`, and
14
+ * `reindexThread` rebuilds it from there at any time — so dropping it loses a search box for as long
15
+ * as it takes to rebuild, and nothing else. That is the line: a migration is for schema whose loss
16
+ * loses data, and this is a provisioned resource like the R2 bucket and the routing rule, which
17
+ * support already creates the same way.
18
+ *
19
+ * Making it a migration was the mistake, and the failure was specific rather than theoretical.
20
+ * Composing it conditionally on `search.fts` meant turning the flag **off** removed an
21
+ * already-applied migration from the set, and Kysely reads a previously-executed migration that has
22
+ * vanished as corruption:
23
+ *
24
+ * corrupted migrations: previously executed migration 1200_support_0002_search is missing
25
+ *
26
+ * Every capability's migrations for a database compose into one provider, so that throw blocked
27
+ * `pithy migrate` for auth, payments, and email too — a whole-database outage from one capability's
28
+ * config flag, fixable only by knowing to roll back first. Out of the ledger, there is nothing to
29
+ * corrupt and toggling either way is just a re-provision.
30
+ *
31
+ * Both statements are idempotent, so provisioning stays safe to re-run — the property every other
32
+ * step of `pithy support provision` already has.
33
+ */
34
+
35
+ /** The virtual table's name. Snake_case because nothing translates it — every statement here is hand-written. */
36
+ export const SEARCH_TABLE = "pithy_support_search";
37
+
38
+ /**
39
+ * Create the full-text index if it is not already there.
40
+ *
41
+ * A standalone (not external-content) FTS5 table. External content would store the text once rather
42
+ * than twice, but it requires the indexed column names to exist verbatim on the content table, and
43
+ * ours is `text_body` rather than `body`. Duplicating is the honest trade for an index that cannot
44
+ * desynchronize on a column rename.
45
+ *
46
+ * `thread_id` and `message_id` are UNINDEXED: they are carried so a match resolves straight to a
47
+ * conversation and so one message's rows can be replaced, and tokenizing a UUID would only add noise
48
+ * to every query.
49
+ */
50
+ export async function createSearchIndex(db: SupportDatabase | Kysely<unknown>): Promise<void> {
51
+ await sql`
52
+ create virtual table if not exists pithy_support_search using fts5(
53
+ thread_id unindexed,
54
+ message_id unindexed,
55
+ subject,
56
+ body,
57
+ tokenize = 'unicode61 remove_diacritics 2'
58
+ )
59
+ `.execute(db as Kysely<unknown>);
60
+ }
61
+
62
+ /**
63
+ * Drop the full-text index if it is there.
64
+ *
65
+ * Safe by construction: the index is derived, so this costs a rebuild rather than a restore. It is
66
+ * what `pithy support provision` runs when `search.fts` goes back to false — and the reason turning
67
+ * the feature off is now a one-command operation instead of a migration rollback.
68
+ */
69
+ export async function dropSearchIndex(db: SupportDatabase | Kysely<unknown>): Promise<void> {
70
+ await sql`drop table if exists pithy_support_search`.execute(db as Kysely<unknown>);
71
+ }