@pithy-sh/support 0.3.3 → 0.4.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 (42) 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.map +1 -1
  6. package/dist/http/routes.js +60 -12
  7. package/dist/inbound/handler.d.ts.map +1 -1
  8. package/dist/inbound/handler.js +0 -1
  9. package/dist/inbound/ingest.d.ts +0 -2
  10. package/dist/inbound/ingest.d.ts.map +1 -1
  11. package/dist/inbound/ingest.js +0 -14
  12. package/dist/provision/provisionSupport.d.ts +12 -3
  13. package/dist/provision/provisionSupport.d.ts.map +1 -1
  14. package/dist/reply/send.d.ts +0 -2
  15. package/dist/reply/send.d.ts.map +1 -1
  16. package/dist/reply/send.js +2 -15
  17. package/dist/store/search.d.ts +16 -9
  18. package/dist/store/search.d.ts.map +1 -1
  19. package/dist/store/search.js +16 -9
  20. package/dist/store/searchIndex.d.ts +131 -5
  21. package/dist/store/searchIndex.d.ts.map +1 -1
  22. package/dist/store/searchIndex.js +151 -6
  23. package/dist/store/threads.d.ts.map +1 -1
  24. package/dist/store/threads.js +3 -2
  25. package/dist/submission/submit.d.ts +0 -2
  26. package/dist/submission/submit.d.ts.map +1 -1
  27. package/dist/submission/submit.js +0 -14
  28. package/dist/version.generated.d.ts +1 -1
  29. package/dist/version.generated.js +1 -1
  30. package/package.json +9 -9
  31. package/src/ai/classify.ts +7 -3
  32. package/src/http/handlers.ts +2 -3
  33. package/src/http/routes.ts +92 -18
  34. package/src/inbound/handler.ts +0 -1
  35. package/src/inbound/ingest.ts +0 -14
  36. package/src/provision/provisionSupport.ts +12 -5
  37. package/src/reply/send.ts +2 -13
  38. package/src/store/search.ts +16 -9
  39. package/src/store/searchIndex.ts +176 -5
  40. package/src/store/threads.ts +2 -1
  41. package/src/submission/submit.ts +0 -11
  42. package/src/version.generated.ts +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"searchIndex.d.ts","sourceRoot":"","sources":["../../src/store/searchIndex.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,MAAM,EAAO,MAAM,QAAQ,CAAC;AAC1C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAEtD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,iHAAiH;AACjH,eAAO,MAAM,YAAY,yBAAyB,CAAC;AAEnD;;;;;;;;;;;GAWG;AACH,wBAAsB,iBAAiB,CAAC,EAAE,EAAE,eAAe,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAU5F;AAED;;;;;;GAMG;AACH,wBAAsB,eAAe,CAAC,EAAE,EAAE,eAAe,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAE1F"}
1
+ {"version":3,"file":"searchIndex.d.ts","sourceRoot":"","sources":["../../src/store/searchIndex.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,MAAM,EAAO,MAAM,QAAQ,CAAC;AAC1C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAEtD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0DG;AAEH,iHAAiH;AACjH,eAAO,MAAM,YAAY,yBAAyB,CAAC;AAEnD;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe,YAC1B,yBAAyB,EACzB,yBAAyB,EACzB,yBAAyB,CACjB,CAAC;AAEX;;;;;GAKG;AACH,eAAO,MAAM,cAAc,EAAE,SAAS,MAAM,EAAuC,CAAC;AAEpF,0FAA0F;AAC1F,MAAM,WAAW,gBAAgB;IAC/B,6CAA6C;IAC7C,KAAK,EAAE,OAAO,CAAC;IACf,4EAA4E;IAC5E,QAAQ,EAAE,KAAK,GAAG,MAAM,GAAG,MAAM,CAAC;CACnC;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,gBAAgB,CAO3E;AAED,iEAAiE;AACjE,MAAM,MAAM,iBAAiB;AAC3B,kDAAkD;AAChD,MAAM;AACR,qEAAqE;GACnE,QAAQ;AACV,gGAAgG;GAC9F,QAAQ;AACV,mFAAmF;GACjF,MAAM,CAAC;AAEX;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,gBAAgB,EAAE,MAAM,EAAE,OAAO,GAAG,iBAAiB,CAI7F;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,uBAAuB,IAAI,MAAM,EAAE,CAgBlD;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,iBAAiB,CAAC,EAAE,EAAE,eAAe,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAc5F;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,eAAe,CAAC,EAAE,EAAE,eAAe,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAK1F"}
@@ -3,8 +3,8 @@
3
3
  import { sql } from "kysely";
4
4
  //#region src/store/searchIndex.ts
5
5
  /**
6
- * The FTS5 index's lifecycle — created and dropped by `pithy support provision`, **not by a
7
- * migration**.
6
+ * The FTS5 index's lifecycle — the virtual table **and the three triggers that keep it in step**,
7
+ * created and dropped by `pithy support provision`, **not by a migration**.
8
8
  *
9
9
  * ## Why this is not a migration
10
10
  *
@@ -26,13 +26,142 @@ import { sql } from "kysely";
26
26
  * config flag, fixable only by knowing to roll back first. Out of the ledger, there is nothing to
27
27
  * corrupt and toggling either way is just a re-provision.
28
28
  *
29
- * Both statements are idempotent, so provisioning stays safe to re-run — the property every other
29
+ * **The triggers inherit that constraint exactly.** They exist only where the table exists, so they
30
+ * are provisioning and not schema — a `CREATE TRIGGER` composed conditionally on `search.fts` would
31
+ * corrupt the ledger for exactly the same reason the table's own statement did. `migrations.workers.test.ts`
32
+ * keeps proving the migration set carries none.
33
+ *
34
+ * ## Why triggers at all
35
+ *
36
+ * The index used to be written by hand at every message write — three call sites, each best-effort —
37
+ * and correctness depended on every future author remembering. There is no shared message-write
38
+ * helper, so a fourth path that forgot produced a thread nobody could search, with nothing failing,
39
+ * nothing logging and no test catching it.
40
+ *
41
+ * `CREATE TRIGGER` was in an undocumented middle on D1 when that was decided: D1 runs an authorizer
42
+ * that rejects SQLite constructs workerd permits (`fts5vocab` is refused where plain FTS5 succeeds),
43
+ * so a trigger passing under Miniflare proved nothing about the deployment that matters. That is
44
+ * settled — the Leed CMS ships a `BEFORE UPDATE … BEGIN SELECT RAISE(ABORT, …); END` trigger in a
45
+ * production migration, so the authorizer permits both the statement and a body that aborts.
46
+ *
47
+ * **The durability contract inverts, deliberately.** A trigger runs inside the message's own
48
+ * statement, so a failure in its body takes the message write down with it — where the old call sites
49
+ * logged and carried on. That swallow existed because a second round trip to a separate table can
50
+ * fail on its own; inside one statement it cannot. The body is a `DELETE` plus an `INSERT` of values
51
+ * already present in `NEW.*`, with no conversion, no constraint and no foreign key, so what remains is
52
+ * the FTS table being absent — which is what {@link SEARCH_TRIGGERS} cannot outlive, since
53
+ * {@link dropSearchIndex} drops the pair together — or disk and corruption, which would have failed the
54
+ * message insert anyway.
55
+ *
56
+ * The one gap the kit cannot close from here is an adopter who upgrades and deploys without re-running
57
+ * `pithy support provision`: the calls are gone, the triggers were never created, and messages
58
+ * silently stop being indexed. `pithy doctor` reports a provisioned table with no triggers as drift
59
+ * and names that command, per environment.
60
+ *
61
+ * Every statement here is idempotent, so provisioning stays safe to re-run — the property every other
30
62
  * step of `pithy support provision` already has.
31
63
  */
32
64
  /** The virtual table's name. Snake_case because nothing translates it — every statement here is hand-written. */
33
65
  const SEARCH_TABLE = "pithy_support_search";
34
66
  /**
35
- * Create the full-text index if it is not already there.
67
+ * The three triggers, in create order — `_ai`, `_au`, `_ad` after SQLite's own FTS5 naming for the
68
+ * insert, update and delete hooks.
69
+ *
70
+ * Exported because two other places need the exact names and may not re-derive them: `pithy doctor`
71
+ * asks a deployed database whether they are there, and {@link dropSearchIndex} drops them. They carry
72
+ * the `pithy_support_` prefix every table in this capability does, for the same reason — `sqlite_master`
73
+ * is one flat namespace per database and the prefix is the only partition.
74
+ */
75
+ const SEARCH_TRIGGERS = [
76
+ "pithy_support_search_ai",
77
+ "pithy_support_search_au",
78
+ "pithy_support_search_ad"
79
+ ];
80
+ /**
81
+ * Every name a provisioner or `pithy doctor` asks `sqlite_master` about — the table and its triggers.
82
+ *
83
+ * One list, read by both, because the two have to agree about what "provisioned" means. A doctor that
84
+ * looked for two triggers while the provisioner created three would report drift nothing could clear.
85
+ */
86
+ const SEARCH_OBJECTS = [SEARCH_TABLE, ...SEARCH_TRIGGERS];
87
+ /**
88
+ * Read a `sqlite_master` name listing into the two facts anything acts on.
89
+ *
90
+ * **The trigger count is three-valued, and `"some"` is why.** A partial set means one `CREATE TRIGGER`
91
+ * failed or one was dropped by hand, and the consequence is per statement kind — two of three leaves
92
+ * updates or deletes going unindexed while inserts look fine, which is the worst way round. Folding it
93
+ * into `"none"` would also hide it from a teardown: `search.fts: false` has to clear a stray trigger,
94
+ * because a trigger whose table is gone fails every write to `pithy_support_messages`.
95
+ */
96
+ function searchIndexState(names) {
97
+ const found = new Set(names);
98
+ const present = SEARCH_TRIGGERS.filter((name) => found.has(name)).length;
99
+ return {
100
+ table: found.has(SEARCH_TABLE),
101
+ triggers: present === SEARCH_TRIGGERS.length ? "all" : present === 0 ? "none" : "some"
102
+ };
103
+ }
104
+ /**
105
+ * Decide what `pithy support provision` does to one environment's index.
106
+ *
107
+ * **`repair` is the member this release exists for.** A run used to compare one boolean against one
108
+ * boolean — table present against flag set — and return early when they matched. That is exactly the
109
+ * state an adopter is in after upgrading and deploying: the table is present, the flag is set, the
110
+ * `indexMessage` calls are gone from every write path, and the triggers were never created. The old
111
+ * comparison read that as nothing to do, so the command `pithy doctor` names would not have been the
112
+ * command that fixes it.
113
+ *
114
+ * A repair backfills as a create does, because the gap is real: messages written between the deploy
115
+ * and the re-provision had no trigger to fire for them and no application call either.
116
+ *
117
+ * `drop` fires on triggers alone as well as on the table. A trigger body resolves its table at run
118
+ * time, so a trigger outlives a hand-dropped table — and while it does, every write to
119
+ * `pithy_support_messages` fails. Turning the flag off has to clear that rather than read it as a
120
+ * database with no index.
121
+ */
122
+ function searchIndexAction(state, wanted) {
123
+ if (!wanted) return state.table || state.triggers !== "none" ? "drop" : "none";
124
+ if (!state.table) return "create";
125
+ return state.triggers === "all" ? "none" : "repair";
126
+ }
127
+ /**
128
+ * The `CREATE TRIGGER` statements, one per {@link SEARCH_TRIGGERS} entry and in that order.
129
+ *
130
+ * **`BEGIN` and `END` are uppercase, and that is load-bearing.** Miniflare accepts a lowercase
131
+ * compound-statement body; remote D1 rejects it as `incomplete input [code: 7500]`, so a lowercased
132
+ * body passes every local test in this repository and fails on deploy. `searchIndex.test.ts` fails on
133
+ * the lowercase form, because no Workers test can.
134
+ *
135
+ * **Every column is the physical snake_case name.** `CamelCasePlugin` snake-cases the identifiers the
136
+ * query builder emits and never touches a string inside a `sql` template, so this is the one place in
137
+ * the package that has to spell them itself: `NEW.id`, `NEW.thread_id`, `NEW.subject`, `NEW.text_body`
138
+ * — never the camelCase keys `data/tables.ts` declares.
139
+ *
140
+ * **Insert and update both delete first.** An FTS5 table has no primary key and no unique constraint
141
+ * to lean on, so a bare insert on a second write for the same message would add a second copy and the
142
+ * thread would start appearing twice in its own search results — the same reason `indexMessage` is
143
+ * remove-then-insert. The update body keys its delete on `OLD.id`, so a row whose id changed takes its
144
+ * old index entry with it.
145
+ */
146
+ function searchTriggerStatements() {
147
+ const insert = "INSERT INTO pithy_support_search(thread_id, message_id, subject, body)";
148
+ const values = "VALUES (NEW.thread_id, NEW.id, NEW.subject, NEW.text_body)";
149
+ return [
150
+ `CREATE TRIGGER ${SEARCH_TRIGGERS[0]} AFTER INSERT ON pithy_support_messages BEGIN
151
+ DELETE FROM pithy_support_search WHERE message_id = NEW.id;
152
+ ${insert} ${values};
153
+ END`,
154
+ `CREATE TRIGGER ${SEARCH_TRIGGERS[1]} AFTER UPDATE ON pithy_support_messages BEGIN
155
+ DELETE FROM pithy_support_search WHERE message_id = OLD.id;
156
+ ${insert} ${values};
157
+ END`,
158
+ `CREATE TRIGGER ${SEARCH_TRIGGERS[2]} AFTER DELETE ON pithy_support_messages BEGIN
159
+ DELETE FROM pithy_support_search WHERE message_id = OLD.id;
160
+ END`
161
+ ];
162
+ }
163
+ /**
164
+ * Create the full-text index and its triggers if they are not already there.
36
165
  *
37
166
  * A standalone (not external-content) FTS5 table. External content would store the text once rather
38
167
  * than twice, but it requires the indexed column names to exist verbatim on the content table, and
@@ -42,6 +171,13 @@ const SEARCH_TABLE = "pithy_support_search";
42
171
  * `thread_id` and `message_id` are UNINDEXED: they are carried so a match resolves straight to a
43
172
  * conversation and so one message's rows can be replaced, and tokenizing a UUID would only add noise
44
173
  * to every query.
174
+ *
175
+ * **The triggers are dropped and recreated rather than guarded with `IF NOT EXISTS`**, which is what
176
+ * makes re-provisioning after a kit upgrade actually converge. `IF NOT EXISTS` is idempotent in the
177
+ * weak sense — it leaves a trigger whose body this release has changed exactly as it was, so a fixed
178
+ * body would reach nobody who already had the old one, and the command `pithy doctor` names would not
179
+ * be the command that fixes it. The pair is ordered so the table is always there before a body refers
180
+ * to it.
45
181
  */
46
182
  async function createSearchIndex(db) {
47
183
  await sql`
@@ -53,16 +189,25 @@ async function createSearchIndex(db) {
53
189
  tokenize = 'unicode61 remove_diacritics 2'
54
190
  )
55
191
  `.execute(db);
192
+ for (const [index, statement] of searchTriggerStatements().entries()) {
193
+ await sql.raw(`drop trigger if exists ${SEARCH_TRIGGERS[index]}`).execute(db);
194
+ await sql.raw(statement).execute(db);
195
+ }
56
196
  }
57
197
  /**
58
- * Drop the full-text index if it is there.
198
+ * Drop the full-text index and its triggers if they are there.
59
199
  *
60
200
  * Safe by construction: the index is derived, so this costs a rebuild rather than a restore. It is
61
201
  * what `pithy support provision` runs when `search.fts` goes back to false — and the reason turning
62
202
  * the feature off is now a one-command operation instead of a migration rollback.
203
+ *
204
+ * **The triggers go first, and the order is the whole point.** A trigger whose body inserts into a
205
+ * table that is gone fails every write to `pithy_support_messages`, so dropping the table out from
206
+ * under them would turn a feature flag into an outage on the capability's main write path.
63
207
  */
64
208
  async function dropSearchIndex(db) {
209
+ for (const trigger of SEARCH_TRIGGERS) await sql.raw(`drop trigger if exists ${trigger}`).execute(db);
65
210
  await sql`drop table if exists pithy_support_search`.execute(db);
66
211
  }
67
212
  //#endregion
68
- export { SEARCH_TABLE, createSearchIndex, dropSearchIndex };
213
+ export { SEARCH_OBJECTS, SEARCH_TABLE, SEARCH_TRIGGERS, createSearchIndex, dropSearchIndex, searchIndexAction, searchIndexState, searchTriggerStatements };
@@ -1 +1 @@
1
- {"version":3,"file":"threads.d.ts","sourceRoot":"","sources":["../../src/store/threads.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"threads.d.ts","sourceRoot":"","sources":["../../src/store/threads.ts"],"names":[],"mappings":"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"}
@@ -5,8 +5,9 @@ import { SupportAttachment } from "../data/attachment.js";
5
5
  import { SupportMessage } from "../data/message.js";
6
6
  import { SupportThread } from "../data/thread.js";
7
7
  import { SUPPORT_ATTACHMENTS_TABLE, SUPPORT_FLAGS_TABLE, SUPPORT_MESSAGES_TABLE, SUPPORT_THREADS_TABLE } from "../data/tables.js";
8
- import { isSearchable, searchPredicate } from "./search.js";
9
8
  import "./paging.js";
9
+ import { isSearchable, searchPredicate } from "./search.js";
10
+ import { pithyCommandOf } from "@pithy-sh/core/src/cli/command";
10
11
  import { sql } from "kysely";
11
12
  //#region src/store/threads.ts
12
13
  /**
@@ -50,7 +51,7 @@ async function listThreads(db, query, options) {
50
51
  return await runListThreads(db, query, options);
51
52
  } catch (error) {
52
53
  if (options.fts && isMissingSearchTable(error)) {
53
- options.log?.warn("support FTS index is configured but absent — falling back to a LIKE scan", { action: "Run `pithy support provision` to create and backfill it, or set `search.fts: false`." });
54
+ options.log?.warn("support FTS index is configured but absent — falling back to a LIKE scan", { action: `Run \`${pithyCommandOf("support provision", {}, { runtime: true })}\` to create and backfill it, or set \`search.fts: false\`.` });
54
55
  return runListThreads(db, query, {
55
56
  ...options,
56
57
  fts: false
@@ -90,8 +90,6 @@ export interface SubmitDeps {
90
90
  categories: SupportCategories;
91
91
  /** The R2 bucket attachments are written to. Absent means none are stored. */
92
92
  bucket?: R2Bucket;
93
- /** Whether the FTS5 index is composed. */
94
- fts: boolean;
95
93
  /**
96
94
  * The submitter's account, by id. Returns null when `@pithy-sh/auth` is absent or the row is gone —
97
95
  * both of which contradict the session that got here, so the caller treats it as a fault rather than
@@ -1 +1 @@
1
- {"version":3,"file":"submit.d.ts","sourceRoot":"","sources":["../../src/submission/submit.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AAC1D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mCAAmC,CAAC;AAGnE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kCAAkC,CAAC;AAG/D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,OAAO,EAAgB,KAAK,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC1E,OAAO,EAAkB,KAAK,wBAAwB,EAAE,MAAM,iBAAiB,CAAC;AAChF,OAAO,EAIL,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;AAOxB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAEH,kCAAkC;AAClC,MAAM,WAAW,aAAa;IAC5B,8DAA8D;IAC9D,QAAQ,EAAE,MAAM,CAAC;IACjB,kCAAkC;IAClC,SAAS,EAAE,MAAM,CAAC;IAClB,6DAA6D;IAC7D,SAAS,EAAE,OAAO,CAAC;IACnB,wCAAwC;IACxC,WAAW,EAAE,MAAM,CAAC;IACpB,+CAA+C;IAC/C,WAAW,EAAE,OAAO,CAAC;CACtB;AAED,sDAAsD;AACtD,MAAM,WAAW,mBAAmB;IAClC,kFAAkF;IAClF,QAAQ,EAAE,MAAM,CAAC;IACjB,yFAAyF;IACzF,WAAW,EAAE,MAAM,CAAC;IACpB,4BAA4B;IAC5B,KAAK,EAAE,UAAU,CAAC;CACnB;AAED,0FAA0F;AAC1F,MAAM,WAAW,UAAU;IACzB,0BAA0B;IAC1B,EAAE,EAAE,eAAe,CAAC;IACpB,2BAA2B;IAC3B,MAAM,EAAE,aAAa,CAAC;IACtB;;;;;;;OAOG;IACH,UAAU,EAAE,iBAAiB,CAAC;IAC9B,8EAA8E;IAC9E,MAAM,CAAC,EAAE,QAAQ,CAAC;IAClB,0CAA0C;IAC1C,GAAG,EAAE,OAAO,CAAC;IACb;;;;OAIG;IACH,cAAc,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAC;IACrF,2FAA2F;IAC3F,gBAAgB,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC1D,sBAAsB;IACtB,IAAI,EAAE,SAAS,CAAC;IAChB,0BAA0B;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,yBAAyB;IACzB,KAAK,EAAE,MAAM,MAAM,CAAC;IACpB,WAAW;IACX,GAAG,EAAE,MAAM,IAAI,CAAC;CACjB;AAED,sGAAsG;AACtG,MAAM,WAAW,WAAW;IAC1B,6EAA6E;IAC7E,MAAM,EAAE,MAAM,CAAC;IACf,iGAAiG;IACjG,OAAO,EAAE,MAAM,CAAC;IAChB,iDAAiD;IACjD,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,uGAAuG;IACvG,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4CAA4C;IAC5C,OAAO,CAAC,EAAE,wBAAwB,CAAC;IACnC,2CAA2C;IAC3C,WAAW,EAAE,SAAS,mBAAmB,EAAE,CAAC;CAC7C;AA0MD;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAG7G;AAED,kCAAkC;AAClC,wBAAsB,cAAc,CAAC,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,aAAa,CAAC,CAmKjG"}
1
+ {"version":3,"file":"submit.d.ts","sourceRoot":"","sources":["../../src/submission/submit.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AAC1D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mCAAmC,CAAC;AAGnE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kCAAkC,CAAC;AAG/D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,OAAO,EAAgB,KAAK,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC1E,OAAO,EAAkB,KAAK,wBAAwB,EAAE,MAAM,iBAAiB,CAAC;AAChF,OAAO,EAIL,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;AAMxB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAEH,kCAAkC;AAClC,MAAM,WAAW,aAAa;IAC5B,8DAA8D;IAC9D,QAAQ,EAAE,MAAM,CAAC;IACjB,kCAAkC;IAClC,SAAS,EAAE,MAAM,CAAC;IAClB,6DAA6D;IAC7D,SAAS,EAAE,OAAO,CAAC;IACnB,wCAAwC;IACxC,WAAW,EAAE,MAAM,CAAC;IACpB,+CAA+C;IAC/C,WAAW,EAAE,OAAO,CAAC;CACtB;AAED,sDAAsD;AACtD,MAAM,WAAW,mBAAmB;IAClC,kFAAkF;IAClF,QAAQ,EAAE,MAAM,CAAC;IACjB,yFAAyF;IACzF,WAAW,EAAE,MAAM,CAAC;IACpB,4BAA4B;IAC5B,KAAK,EAAE,UAAU,CAAC;CACnB;AAED,0FAA0F;AAC1F,MAAM,WAAW,UAAU;IACzB,0BAA0B;IAC1B,EAAE,EAAE,eAAe,CAAC;IACpB,2BAA2B;IAC3B,MAAM,EAAE,aAAa,CAAC;IACtB;;;;;;;OAOG;IACH,UAAU,EAAE,iBAAiB,CAAC;IAC9B,8EAA8E;IAC9E,MAAM,CAAC,EAAE,QAAQ,CAAC;IAClB;;;;OAIG;IACH,cAAc,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAC;IACrF,2FAA2F;IAC3F,gBAAgB,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC1D,sBAAsB;IACtB,IAAI,EAAE,SAAS,CAAC;IAChB,0BAA0B;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,yBAAyB;IACzB,KAAK,EAAE,MAAM,MAAM,CAAC;IACpB,WAAW;IACX,GAAG,EAAE,MAAM,IAAI,CAAC;CACjB;AAED,sGAAsG;AACtG,MAAM,WAAW,WAAW;IAC1B,6EAA6E;IAC7E,MAAM,EAAE,MAAM,CAAC;IACf,iGAAiG;IACjG,OAAO,EAAE,MAAM,CAAC;IAChB,iDAAiD;IACjD,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,uGAAuG;IACvG,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4CAA4C;IAC5C,OAAO,CAAC,EAAE,wBAAwB,CAAC;IACnC,2CAA2C;IAC3C,WAAW,EAAE,SAAS,mBAAmB,EAAE,CAAC;CAC7C;AA0MD;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAG7G;AAED,kCAAkC;AAClC,wBAAsB,cAAc,CAAC,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,aAAa,CAAC,CA2JjG"}
@@ -8,7 +8,6 @@ import { SupportThread, UNCLASSIFIED } from "../data/thread.js";
8
8
  import { SUPPORT_ATTACHMENTS_TABLE, SUPPORT_MESSAGES_TABLE, SUPPORT_THREADS_TABLE } from "../data/tables.js";
9
9
  import { attachmentKey, putAttachment, sha256Hex } from "../attachment/store.js";
10
10
  import { SupportAuditActions } from "../audit/actions.js";
11
- import { indexMessage } from "../store/search.js";
12
11
  import { safeFilename } from "../mime/parse.js";
13
12
  import { checkAccountRate, checkAttachment } from "./guard.js";
14
13
  import { InternalError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
@@ -278,19 +277,6 @@ async function submitFeedback(deps, input) {
278
277
  });
279
278
  throw error;
280
279
  }
281
- if (deps.fts) try {
282
- await indexMessage(deps.db, {
283
- threadId,
284
- messageId,
285
- subject: input.subject,
286
- body: input.body
287
- });
288
- } catch (error) {
289
- deps.log.warn("support submission not indexed", {
290
- messageId,
291
- error
292
- });
293
- }
294
280
  let attachments = 0;
295
281
  try {
296
282
  attachments = await storeAttachments(deps, input.attachments, threadId, messageId, now);
@@ -3,5 +3,5 @@
3
3
  /** This package's npm name — the join key against a release feed. */
4
4
  export declare const PACKAGE_NAME = "@pithy-sh/support";
5
5
  /** This package's version, stamped from its own package.json at generation time. */
6
- export declare const PACKAGE_VERSION = "0.3.3";
6
+ export declare const PACKAGE_VERSION = "0.4.0";
7
7
  //# sourceMappingURL=version.generated.d.ts.map
@@ -4,6 +4,6 @@
4
4
  /** This package's npm name — the join key against a release feed. */
5
5
  const PACKAGE_NAME = "@pithy-sh/support";
6
6
  /** This package's version, stamped from its own package.json at generation time. */
7
- const PACKAGE_VERSION = "0.3.3";
7
+ const PACKAGE_VERSION = "0.4.0";
8
8
  //#endregion
9
9
  export { PACKAGE_NAME, PACKAGE_VERSION };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pithy-sh/support",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,12 +40,12 @@
40
40
  "tldts": "^7.4.10"
41
41
  },
42
42
  "peerDependencies": {
43
- "@pithy-sh/auth": "^0.7.0",
44
- "@pithy-sh/core": "^0.9.0",
45
- "@pithy-sh/email": "^0.3.11",
46
- "@pithy-sh/payments": "^0.3.10",
47
- "@pithy-sh/secrets": "^0.3.2",
48
- "@pithy-sh/storage": "^0.2.10",
43
+ "@pithy-sh/auth": "^0.8.0",
44
+ "@pithy-sh/core": "^0.10.0",
45
+ "@pithy-sh/email": "^0.4.0",
46
+ "@pithy-sh/payments": "^0.4.0",
47
+ "@pithy-sh/secrets": "^0.4.0",
48
+ "@pithy-sh/storage": "^0.3.0",
49
49
  "hono": "^4.13.2",
50
50
  "kysely": "^0.29.0",
51
51
  "zod": "^4.4.0"
@@ -59,7 +59,7 @@
59
59
  }
60
60
  },
61
61
  "devDependencies": {
62
- "@cloudflare/vitest-plugin": "^1.0.0",
62
+ "@cloudflare/vitest-plugin": "^1.4.2",
63
63
  "@pithy-sh/auth": "workspace:*",
64
64
  "@pithy-sh/core": "workspace:*",
65
65
  "@pithy-sh/email": "workspace:*",
@@ -75,7 +75,7 @@
75
75
  "tsdown": "^0.23.0",
76
76
  "typescript": "^7.0.2",
77
77
  "vitest": "^4.1.0",
78
- "wrangler": "^4.115.0",
78
+ "wrangler": "^4.151.0",
79
79
  "zod": "^4.4.0"
80
80
  }
81
81
  }
@@ -177,8 +177,12 @@ export async function classifyMessage(
177
177
  // would have called an outage and the step would have re-driven five times against an env that is
178
178
  // not going to grow a binding. This is the deployment error `support/classification_failed` names,
179
179
  // and it is terminal.
180
- const run = (ai as Partial<SupportAi> | undefined)?.run;
181
- if (typeof run !== "function") {
180
+ //
181
+ // The presence test reads `run`; the call below goes through the binding itself. A Workers binding
182
+ // can be an RPC stub, where a property read is another stub and `.call` on it is a remote call to a
183
+ // method named `call` — the fault that broke `classifiedSteps().sleep` on a live `WorkflowStep`.
184
+ const binding = ai as Partial<SupportAi> | undefined;
185
+ if (typeof binding?.run !== "function") {
182
186
  throw new SupportClassificationError({
183
187
  detail: "the AI binding is absent from this worker's env; classification cannot run",
184
188
  });
@@ -186,7 +190,7 @@ export async function classifyMessage(
186
190
 
187
191
  let raw: unknown;
188
192
  try {
189
- raw = await run.call(ai, options.model, {
193
+ raw = await binding.run(options.model, {
190
194
  messages: [
191
195
  { role: "system", content: classificationPrompt(options.categories) },
192
196
  { role: "user", content: classificationInput(options.subject, options.body, options.maxChars) },
@@ -3,6 +3,7 @@
3
3
 
4
4
  import type { D1Database, R2Bucket } from "@cloudflare/workers-types";
5
5
  import type { AuditEmit } from "@pithy-sh/core/src/audit/recorder";
6
+ import { pithyCommandOf } from "@pithy-sh/core/src/cli/command";
6
7
  import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
7
8
  import type { Logger } from "@pithy-sh/core/src/logger/logger";
8
9
  import type { ObjectStore } from "@pithy-sh/storage/src/object/store";
@@ -221,7 +222,6 @@ export async function replyToConversation(
221
222
  db: deps.db,
222
223
  config: deps.config,
223
224
  enqueue: deps.enqueue,
224
- fts: deps.fts,
225
225
  emit: deps.emit,
226
226
  log: deps.log,
227
227
  newId: deps.newId,
@@ -252,7 +252,7 @@ export async function reclassifyConversation(
252
252
  // ran — forever, and with a success event in the audit trail to corroborate it.
253
253
  throw new SupportClassificationError({
254
254
  message: "Classification is not available on this deployment.",
255
- action: "Run `pithy support provision` to deploy the classification worker, then retry.",
255
+ action: `Run \`${pithyCommandOf("support provision", {}, { runtime: true })}\` to deploy the classification worker, then retry.`,
256
256
  detail: `the ${"SUPPORT_CLASSIFY"} workflow binding is absent, so no instance was started`,
257
257
  });
258
258
  }
@@ -353,7 +353,6 @@ export async function submitFeedbackRequest(
353
353
  // *stored* belongs with the write, so it holds for every caller and not only for HTTP.
354
354
  categories: deps.categories,
355
355
  bucket: deps.bucket,
356
- fts: deps.fts,
357
356
  resolveAccount: (id) => resolveSubmitterAccount(deps.d1, id, deps.peers),
358
357
  dispatchClassify: deps.dispatchClassify,
359
358
  emit: deps.emit,
@@ -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,
@@ -192,40 +203,70 @@ export function registerSupportRoutes(options: SupportRoutesOptions): (app: Hono
192
203
  return (app) => {
193
204
  app.get(
194
205
  `${base}/threads`,
206
+ describeRoute({
207
+ summary: "List the support inbox.",
208
+ verification: "control-plane",
209
+ scopes: [SUPPORT_THREADS_READ_SCOPE],
210
+ responses: { 200: SupportThreadsResponse },
211
+ }),
195
212
  requireControlPlane(SUPPORT_THREADS_READ_SCOPE),
196
- zValidator("query", ListThreadsQuery, validationHook),
213
+ validate("query", ListThreadsQuery),
197
214
  async (c) => c.json(await listInbox(await resolve(c), c.req.valid("query"), viewer(c))),
198
215
  );
199
216
 
200
217
  app.get(
201
218
  `${base}/replies`,
219
+ describeRoute({
220
+ summary: "List the canned replies.",
221
+ verification: "control-plane",
222
+ scopes: [SUPPORT_THREADS_READ_SCOPE],
223
+ responses: { 200: SupportRepliesResponse },
224
+ }),
202
225
  requireControlPlane(SUPPORT_THREADS_READ_SCOPE),
203
- zValidator("query", RepliesQuery, validationHook),
226
+ validate("query", RepliesQuery),
204
227
  async (c) =>
205
228
  c.json({ replies: listReplies(await resolve(c), c.req.valid("query")) } satisfies SupportRepliesResponse),
206
229
  );
207
230
 
208
231
  app.get(
209
232
  `${base}/threads/:id`,
233
+ describeRoute({
234
+ summary: "Read one conversation and its messages.",
235
+ verification: "control-plane",
236
+ scopes: [SUPPORT_THREADS_READ_SCOPE],
237
+ responses: { 200: SupportThreadResponse },
238
+ }),
210
239
  requireControlPlane(SUPPORT_THREADS_READ_SCOPE),
211
- zValidator("param", ThreadIdParam, validationHook),
240
+ validate("param", ThreadIdParam),
212
241
  async (c) => c.json(await readConversation(await resolve(c), c.req.valid("param").id)),
213
242
  );
214
243
 
215
244
  app.post(
216
245
  `${base}/threads/:id/archive`,
246
+ describeRoute({
247
+ summary: "Mark a conversation done, or reopen it.",
248
+ verification: "control-plane",
249
+ scopes: [SUPPORT_THREADS_ARCHIVE_SCOPE],
250
+ responses: { 200: SupportArchiveResponse },
251
+ }),
217
252
  requireControlPlane(SUPPORT_THREADS_ARCHIVE_SCOPE),
218
- zValidator("param", ThreadIdParam, validationHook),
219
- zValidator("json", ArchiveThreadInput, validationHook),
253
+ validate("param", ThreadIdParam),
254
+ validate("json", ArchiveThreadInput),
220
255
  async (c) =>
221
256
  c.json(await archiveConversation(await resolve(c), c.req.valid("param").id, c.req.valid("json"), viewer(c))),
222
257
  );
223
258
 
224
259
  app.post(
225
260
  `${base}/threads/:id/reply`,
261
+ describeRoute({
262
+ summary: "Answer the customer.",
263
+ verification: "control-plane",
264
+ scopes: [SUPPORT_THREADS_REPLY_SCOPE],
265
+ responses: { 201: SupportReplySentResponse },
266
+ }),
226
267
  requireControlPlane(SUPPORT_THREADS_REPLY_SCOPE),
227
- zValidator("param", ThreadIdParam, validationHook),
228
- zValidator("json", ReplyInput, validationHook),
268
+ validate("param", ThreadIdParam),
269
+ validate("json", ReplyInput),
229
270
  async (c) =>
230
271
  c.json(
231
272
  await replyToConversation(await resolve(c), c.req.valid("param").id, c.req.valid("json"), viewer(c)),
@@ -235,16 +276,28 @@ export function registerSupportRoutes(options: SupportRoutesOptions): (app: Hono
235
276
 
236
277
  app.post(
237
278
  `${base}/threads/:id/reclassify`,
279
+ describeRoute({
280
+ summary: "Run the classifier again on the latest inbound message.",
281
+ verification: "control-plane",
282
+ scopes: [SUPPORT_THREADS_RECLASSIFY_SCOPE],
283
+ responses: { 200: SupportReclassifiedResponse },
284
+ }),
238
285
  requireControlPlane(SUPPORT_THREADS_RECLASSIFY_SCOPE),
239
- zValidator("param", ThreadIdParam, validationHook),
286
+ validate("param", ThreadIdParam),
240
287
  async (c) => c.json(await reclassifyConversation(await resolve(c), c.req.valid("param").id, viewer(c))),
241
288
  );
242
289
 
243
290
  app.post(
244
291
  `${base}/threads/:id/flags`,
292
+ describeRoute({
293
+ summary: "Set the caller's private read and snooze flags.",
294
+ verification: "control-plane",
295
+ scopes: [SUPPORT_THREADS_FLAG_SCOPE],
296
+ responses: { 200: SupportFlagsResponse },
297
+ }),
245
298
  requireControlPlane(SUPPORT_THREADS_FLAG_SCOPE),
246
- zValidator("param", ThreadIdParam, validationHook),
247
- zValidator("json", FlagsInput, validationHook),
299
+ validate("param", ThreadIdParam),
300
+ validate("json", FlagsInput),
248
301
  async (c) => c.json(await updateFlags(await resolve(c), c.req.valid("param").id, c.req.valid("json"), viewer(c))),
249
302
  );
250
303
 
@@ -255,18 +308,39 @@ export function registerSupportRoutes(options: SupportRoutesOptions): (app: Hono
255
308
  // unauthenticated caller which submissions were well-formed.
256
309
  app.post(
257
310
  `${base}/feedback`,
311
+ describeRoute({
312
+ summary: "Write in to support from the app.",
313
+ verification: ["bearer", "session"],
314
+ responses: { 201: SupportSubmissionResponse },
315
+ }),
258
316
  requireAuth(),
259
317
  requireSameOrigin(),
260
- zValidator("json", SubmitFeedbackInput, validationHook),
318
+ validate("json", SubmitFeedbackInput),
261
319
  async (c) => c.json(await submitFeedbackRequest(await resolve(c), c.req.valid("json"), submitter(c)), 201),
262
320
  );
263
321
 
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))),
322
+ app.get(
323
+ `${base}/feedback`,
324
+ describeRoute({
325
+ summary: "List the caller's own support requests.",
326
+ verification: ["bearer", "session"],
327
+ responses: { 200: SupportMyThreadsResponse },
328
+ }),
329
+ requireAuth(),
330
+ validate("query", MyThreadsQuery),
331
+ async (c) => c.json(await listMyThreads(await resolve(c), c.req.valid("query"), submitter(c))),
266
332
  );
267
333
 
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))),
334
+ app.get(
335
+ `${base}/feedback/:id`,
336
+ describeRoute({
337
+ summary: "Read one of the caller's own support requests.",
338
+ verification: ["bearer", "session"],
339
+ responses: { 200: SupportMyThreadResponse },
340
+ }),
341
+ requireAuth(),
342
+ validate("param", ThreadIdParam),
343
+ async (c) => c.json(await readMyThread(await resolve(c), c.req.valid("param").id, submitter(c))),
270
344
  );
271
345
  };
272
346
  }
@@ -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