@pithy-sh/support 0.1.2 → 0.1.4

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 (170) hide show
  1. package/dist/ai/classify.d.ts +92 -0
  2. package/dist/ai/classify.d.ts.map +1 -0
  3. package/dist/ai/classify.js +153 -0
  4. package/dist/attachment/store.d.ts +55 -0
  5. package/dist/attachment/store.d.ts.map +1 -0
  6. package/dist/attachment/store.js +69 -0
  7. package/dist/audit/actions.d.ts +70 -0
  8. package/dist/audit/actions.d.ts.map +1 -0
  9. package/dist/audit/actions.js +70 -0
  10. package/dist/capability.d.ts +95 -0
  11. package/dist/capability.d.ts.map +1 -0
  12. package/dist/capability.js +189 -0
  13. package/dist/client/projection.d.ts +58 -0
  14. package/dist/client/projection.d.ts.map +1 -0
  15. package/dist/client/projection.js +2 -0
  16. package/dist/config/config.d.ts +217 -0
  17. package/dist/config/config.d.ts.map +1 -0
  18. package/dist/config/config.js +171 -0
  19. package/dist/data/attachment.d.ts +32 -0
  20. package/dist/data/attachment.d.ts.map +1 -0
  21. package/dist/data/attachment.js +34 -0
  22. package/dist/data/billingScope.d.ts +32 -0
  23. package/dist/data/billingScope.d.ts.map +1 -0
  24. package/dist/data/billingScope.js +34 -0
  25. package/dist/data/categories.d.ts +56 -0
  26. package/dist/data/categories.d.ts.map +1 -0
  27. package/dist/data/categories.js +99 -0
  28. package/dist/data/classification.d.ts +36 -0
  29. package/dist/data/classification.d.ts.map +1 -0
  30. package/dist/data/classification.js +30 -0
  31. package/dist/data/enums.d.ts +84 -0
  32. package/dist/data/enums.d.ts.map +1 -0
  33. package/dist/data/enums.js +72 -0
  34. package/dist/data/flag.d.ts +27 -0
  35. package/dist/data/flag.d.ts.map +1 -0
  36. package/dist/data/flag.js +29 -0
  37. package/dist/data/message.d.ts +83 -0
  38. package/dist/data/message.d.ts.map +1 -0
  39. package/dist/data/message.js +86 -0
  40. package/dist/data/tables.d.ts +162 -0
  41. package/dist/data/tables.d.ts.map +1 -0
  42. package/dist/data/tables.js +46 -0
  43. package/dist/data/thread.d.ts +79 -0
  44. package/dist/data/thread.d.ts.map +1 -0
  45. package/dist/data/thread.js +67 -0
  46. package/dist/error/errors.d.ts +66 -0
  47. package/dist/error/errors.d.ts.map +1 -0
  48. package/dist/error/errors.js +84 -0
  49. package/dist/http/guards.d.ts +46 -0
  50. package/dist/http/guards.d.ts.map +1 -0
  51. package/dist/http/guards.js +55 -0
  52. package/dist/http/handlers.d.ts +91 -0
  53. package/dist/http/handlers.d.ts.map +1 -0
  54. package/dist/http/handlers.js +250 -0
  55. package/dist/http/resolve.d.ts +46 -0
  56. package/dist/http/resolve.d.ts.map +1 -0
  57. package/dist/http/resolve.js +74 -0
  58. package/dist/http/responses.d.ts +831 -0
  59. package/dist/http/responses.d.ts.map +1 -0
  60. package/dist/http/responses.js +305 -0
  61. package/dist/http/routes.d.ts +111 -0
  62. package/dist/http/routes.d.ts.map +1 -0
  63. package/dist/http/routes.js +64 -0
  64. package/dist/http/schemas.d.ts +123 -0
  65. package/dist/http/schemas.d.ts.map +1 -0
  66. package/dist/http/schemas.js +91 -0
  67. package/dist/http/scopes.d.ts +58 -0
  68. package/dist/http/scopes.d.ts.map +1 -0
  69. package/dist/http/scopes.js +109 -0
  70. package/dist/http/views.d.ts +38 -0
  71. package/dist/http/views.d.ts.map +1 -0
  72. package/dist/http/views.js +146 -0
  73. package/dist/inbound/authenticity.d.ts +83 -0
  74. package/dist/inbound/authenticity.d.ts.map +1 -0
  75. package/dist/inbound/authenticity.js +63 -0
  76. package/dist/inbound/guard.d.ts +76 -0
  77. package/dist/inbound/guard.d.ts.map +1 -0
  78. package/dist/inbound/guard.js +80 -0
  79. package/dist/inbound/handler.d.ts +30 -0
  80. package/dist/inbound/handler.d.ts.map +1 -0
  81. package/dist/inbound/handler.js +84 -0
  82. package/dist/inbound/ingest.d.ts +106 -0
  83. package/dist/inbound/ingest.d.ts.map +1 -0
  84. package/dist/inbound/ingest.js +328 -0
  85. package/dist/inbound/recipient.d.ts +18 -0
  86. package/dist/inbound/recipient.d.ts.map +1 -0
  87. package/dist/inbound/recipient.js +48 -0
  88. package/dist/index.d.ts +22 -0
  89. package/dist/index.d.ts.map +1 -0
  90. package/dist/index.js +16 -0
  91. package/dist/link/sender.d.ts +162 -0
  92. package/dist/link/sender.d.ts.map +1 -0
  93. package/dist/link/sender.js +202 -0
  94. package/dist/migrations/0001_threads.d.ts +28 -0
  95. package/dist/migrations/0001_threads.d.ts.map +1 -0
  96. package/dist/migrations/0001_threads.js +105 -0
  97. package/dist/mime/address.d.ts +24 -0
  98. package/dist/mime/address.d.ts.map +1 -0
  99. package/dist/mime/address.js +30 -0
  100. package/dist/mime/parse.d.ts +98 -0
  101. package/dist/mime/parse.d.ts.map +1 -0
  102. package/dist/mime/parse.js +148 -0
  103. package/dist/mime/sanitize.d.ts +27 -0
  104. package/dist/mime/sanitize.d.ts.map +1 -0
  105. package/dist/mime/sanitize.js +214 -0
  106. package/dist/mime/threading.d.ts +81 -0
  107. package/dist/mime/threading.d.ts.map +1 -0
  108. package/dist/mime/threading.js +113 -0
  109. package/dist/mime/truncate.d.ts +19 -0
  110. package/dist/mime/truncate.d.ts.map +1 -0
  111. package/dist/mime/truncate.js +49 -0
  112. package/dist/provision/provisionSupport.d.ts +160 -0
  113. package/dist/provision/provisionSupport.d.ts.map +1 -0
  114. package/dist/provision/provisionSupport.js +112 -0
  115. package/dist/provision/resolveSupportConfig.d.ts +47 -0
  116. package/dist/provision/resolveSupportConfig.d.ts.map +1 -0
  117. package/dist/provision/resolveSupportConfig.js +24 -0
  118. package/dist/reply/send.d.ts +125 -0
  119. package/dist/reply/send.d.ts.map +1 -0
  120. package/dist/reply/send.js +155 -0
  121. package/dist/reply/snippets.d.ts +81 -0
  122. package/dist/reply/snippets.d.ts.map +1 -0
  123. package/dist/reply/snippets.js +137 -0
  124. package/dist/secret/registry.d.ts +20 -0
  125. package/dist/secret/registry.d.ts.map +1 -0
  126. package/dist/secret/registry.js +23 -0
  127. package/dist/seeds/example.d.ts +5 -0
  128. package/dist/seeds/example.d.ts.map +1 -0
  129. package/dist/seeds/example.js +352 -0
  130. package/dist/store/paging.d.ts +20 -0
  131. package/dist/store/paging.d.ts.map +1 -0
  132. package/dist/store/paging.js +22 -0
  133. package/dist/store/search.d.ts +69 -0
  134. package/dist/store/search.d.ts.map +1 -0
  135. package/dist/store/search.js +162 -0
  136. package/dist/store/searchIndex.d.ts +55 -0
  137. package/dist/store/searchIndex.d.ts.map +1 -0
  138. package/dist/store/searchIndex.js +68 -0
  139. package/dist/store/threads.d.ts +149 -0
  140. package/dist/store/threads.d.ts.map +1 -0
  141. package/dist/store/threads.js +199 -0
  142. package/dist/submission/encoding.d.ts +23 -0
  143. package/dist/submission/encoding.d.ts.map +1 -0
  144. package/dist/submission/encoding.js +57 -0
  145. package/dist/submission/guard.d.ts +73 -0
  146. package/dist/submission/guard.d.ts.map +1 -0
  147. package/dist/submission/guard.js +48 -0
  148. package/dist/submission/submit.d.ts +154 -0
  149. package/dist/submission/submit.d.ts.map +1 -0
  150. package/dist/submission/submit.js +321 -0
  151. package/dist/version.generated.d.ts +7 -0
  152. package/dist/version.generated.d.ts.map +1 -0
  153. package/dist/version.generated.js +9 -0
  154. package/dist/workflows/classify.d.ts +65 -0
  155. package/dist/workflows/classify.d.ts.map +1 -0
  156. package/dist/workflows/classify.js +70 -0
  157. package/dist/workflows/retryPolicy.d.ts +41 -0
  158. package/dist/workflows/retryPolicy.d.ts.map +1 -0
  159. package/dist/workflows/retryPolicy.js +45 -0
  160. package/dist/workflows/specs.d.ts +45 -0
  161. package/dist/workflows/specs.d.ts.map +1 -0
  162. package/dist/workflows/specs.js +48 -0
  163. package/dist/workflows/worker.d.ts +50 -0
  164. package/dist/workflows/worker.d.ts.map +1 -0
  165. package/dist/workflows/worker.js +39 -0
  166. package/package.json +24 -15
  167. package/src/http/handlers.ts +1 -1
  168. package/src/submission/encoding.ts +1 -1
  169. package/src/submission/submit.ts +1 -1
  170. package/src/version.generated.ts +1 -1
@@ -0,0 +1,22 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ //#region src/store/paging.ts
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
+ /** How many threads a page holds by default. */
18
+ const DEFAULT_PAGE_SIZE = 25;
19
+ /** The most a caller may ask for in one page. */
20
+ const MAX_PAGE_SIZE = 100;
21
+ //#endregion
22
+ export { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE };
@@ -0,0 +1,69 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { type Expression, type SqlBool } from "kysely";
4
+ import { type SupportDatabase } from "../data/tables";
5
+ /**
6
+ * Turn a user's words into an FTS5 MATCH expression.
7
+ *
8
+ * Every token is quoted and the whole thing is ANDed. Quoting is what stops FTS5's own query syntax
9
+ * from leaking through: a bare `NOT`, `OR`, `*` or `(` is an operator to FTS5, so a customer whose
10
+ * surname is a keyword — or anyone deliberately probing — would otherwise change the shape of the
11
+ * query rather than search for a word. This is the same "input is data, never syntax" rule the rest
12
+ * of the package applies to model output and to HTML.
13
+ */
14
+ export declare function ftsQuery(term: string): string;
15
+ /** Whether a term has anything searchable left after tokenizing. */
16
+ export declare function isSearchable(term: string, fts: boolean): boolean;
17
+ /**
18
+ * A predicate matching threads whose messages contain `term`.
19
+ *
20
+ * Correlated on the thread id so it drops into a `where` beside every other filter. The subquery is
21
+ * `exists`, so it stops at the first matching message rather than counting them.
22
+ */
23
+ export declare function searchPredicate(term: string, options: {
24
+ fts: boolean;
25
+ }): Expression<SqlBool>;
26
+ /**
27
+ * Add a message to the full-text index, replacing any row already there for it.
28
+ *
29
+ * **Remove-then-insert, not a bare insert** — the same shape the CMS `syncFts` uses, and for the
30
+ * reason application-managed indexes always need it: an FTS5 table has no primary key and no unique
31
+ * constraint to lean on, so a second call for the same message would silently add a second copy and
32
+ * the thread would start appearing twice in its own search results. Making the write idempotent means
33
+ * a retry, a reindex, and a first index are all the same operation, which is the property every other
34
+ * write path in this repo is built on.
35
+ *
36
+ * Best-effort by contract: a failed index write must never lose a customer's message, so the caller
37
+ * logs and carries on — the row is still there, still readable, still repairable by
38
+ * {@link reindexThread}, and only missing from one search box.
39
+ */
40
+ export declare function indexMessage(db: SupportDatabase, entry: {
41
+ threadId: string;
42
+ messageId: string;
43
+ subject: string;
44
+ body: string;
45
+ }): Promise<void>;
46
+ /**
47
+ * Rebuild the index for one thread — delete its rows, then re-add every message.
48
+ *
49
+ * The repair path for the failure mode application-side indexing has and triggers would not: a
50
+ * message stored while the index write failed. Also what a reindex after a tokenizer change runs.
51
+ */
52
+ export declare function reindexThread(db: SupportDatabase, threadId: string): Promise<number>;
53
+ /**
54
+ * Rebuild the whole index from the messages table, in pages.
55
+ *
56
+ * **Creating the index is not the same as populating it**, and that gap is the one failure a search
57
+ * box must not have: an adopter who turns `search.fts` on after mail already exists gets an empty
58
+ * virtual table, and because the table now *exists* the `LIKE` fallback never fires — so the inbox
59
+ * answers "no matches" for a term that is demonstrably in the body. Silently returning nothing is
60
+ * worse than returning slowly, so provisioning backfills immediately after it creates.
61
+ *
62
+ * Paged on `(receivedAt, id)` rather than an offset, for the same reason the inbox is: rows arriving
63
+ * during a long backfill would shift an offset underneath it and skip messages.
64
+ */
65
+ export declare function reindexAll(db: SupportDatabase, options?: {
66
+ pageSize?: number;
67
+ onProgress?: (indexed: number) => void;
68
+ }): Promise<number>;
69
+ //# sourceMappingURL=search.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../src/store/search.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,UAAU,EAAE,KAAK,OAAO,EAAO,MAAM,QAAQ,CAAC;AAC5D,OAAO,EAA0B,KAAK,eAAe,EAAE,MAAM,gBAAgB,CAAC;AA+B9E;;;;;;;;GAQG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAS7C;AAED,oEAAoE;AACpE,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAEhE;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE;IAAE,GAAG,EAAE,OAAO,CAAA;CAAE,GAAG,UAAU,CAAC,OAAO,CAAC,CAkB5F;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,YAAY,CAChC,EAAE,EAAE,eAAe,EACnB,KAAK,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC5E,OAAO,CAAC,IAAI,CAAC,CAMf;AAED;;;;;GAKG;AACH,wBAAsB,aAAa,CAAC,EAAE,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAkB1F;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,UAAU,CAC9B,EAAE,EAAE,eAAe,EACnB,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;CAAO,GAC1E,OAAO,CAAC,MAAM,CAAC,CA4CjB"}
@@ -0,0 +1,162 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { SUPPORT_MESSAGES_TABLE } from "../data/tables.js";
4
+ import { sql } from "kysely";
5
+ //#region src/store/search.ts
6
+ /**
7
+ * Text search over the inbox — two backends behind one predicate.
8
+ *
9
+ * **`LIKE` is the default and FTS5 is the opt-in**, which is the opposite of what it looks like it
10
+ * should be. The reason is in `store/searchIndex.ts`: an FTS5 virtual table anywhere in a D1
11
+ * database makes `wrangler d1 export` refuse to dump *the whole database*, so turning it on by
12
+ * default would quietly cost every adopter their backup story for tables that have nothing to do
13
+ * with support. `LIKE` is an unindexed scan and is completely adequate for an inbox measured in
14
+ * thousands of messages, which is what this capability is for.
15
+ *
16
+ * Both paths return a **predicate over `pithy_support_threads`**, not a list of ids, so search
17
+ * composes with the category/archived filters and — crucially — with cursor pagination. Returning
18
+ * ids would mean either capping the match set (silent truncation, which reads as "that is all there
19
+ * is") or paginating twice over.
20
+ *
21
+ * Either way this is keyword search, deliberately. People search a support inbox for a word they
22
+ * remember — an order number, an error string, a surname — not for a concept. `@pithy-sh/vector` is
23
+ * there for anyone who wants meaning-matching, and it is the nicer demo and the wrong default: it
24
+ * cannot find `ORD-40912`.
25
+ */
26
+ /** The longest search term accepted. Bounded because it becomes a scan predicate. */
27
+ const MAX_TERM = 200;
28
+ /** Escape the `LIKE` wildcards so a term containing `%` matches a literal `%`. */
29
+ function escapeLike(term) {
30
+ return term.replace(/[\\%_]/g, (char) => `\\${char}`);
31
+ }
32
+ /**
33
+ * Turn a user's words into an FTS5 MATCH expression.
34
+ *
35
+ * Every token is quoted and the whole thing is ANDed. Quoting is what stops FTS5's own query syntax
36
+ * from leaking through: a bare `NOT`, `OR`, `*` or `(` is an operator to FTS5, so a customer whose
37
+ * surname is a keyword — or anyone deliberately probing — would otherwise change the shape of the
38
+ * query rather than search for a word. This is the same "input is data, never syntax" rule the rest
39
+ * of the package applies to model output and to HTML.
40
+ */
41
+ function ftsQuery(term) {
42
+ return term.slice(0, MAX_TERM).split(/[^\p{L}\p{N}_-]+/u).filter((token) => token.length > 0).slice(0, 16).map((token) => `"${token.replace(/"/g, "\"\"")}"`).join(" AND ");
43
+ }
44
+ /** Whether a term has anything searchable left after tokenizing. */
45
+ function isSearchable(term, fts) {
46
+ return fts ? ftsQuery(term).length > 0 : term.trim().length > 0;
47
+ }
48
+ /**
49
+ * A predicate matching threads whose messages contain `term`.
50
+ *
51
+ * Correlated on the thread id so it drops into a `where` beside every other filter. The subquery is
52
+ * `exists`, so it stops at the first matching message rather than counting them.
53
+ */
54
+ function searchPredicate(term, options) {
55
+ if (options.fts) return sql`exists (
56
+ select 1 from pithy_support_search
57
+ where pithy_support_search.thread_id = pithy_support_threads.id
58
+ and pithy_support_search match ${ftsQuery(term)}
59
+ )`;
60
+ const pattern = `%${escapeLike(term.slice(0, MAX_TERM))}%`;
61
+ return sql`exists (
62
+ select 1 from pithy_support_messages m
63
+ where m.thread_id = pithy_support_threads.id
64
+ and (m.subject like ${pattern} escape '\\' or m.text_body like ${pattern} escape '\\')
65
+ )`;
66
+ }
67
+ /**
68
+ * Add a message to the full-text index, replacing any row already there for it.
69
+ *
70
+ * **Remove-then-insert, not a bare insert** — the same shape the CMS `syncFts` uses, and for the
71
+ * reason application-managed indexes always need it: an FTS5 table has no primary key and no unique
72
+ * constraint to lean on, so a second call for the same message would silently add a second copy and
73
+ * the thread would start appearing twice in its own search results. Making the write idempotent means
74
+ * a retry, a reindex, and a first index are all the same operation, which is the property every other
75
+ * write path in this repo is built on.
76
+ *
77
+ * Best-effort by contract: a failed index write must never lose a customer's message, so the caller
78
+ * logs and carries on — the row is still there, still readable, still repairable by
79
+ * {@link reindexThread}, and only missing from one search box.
80
+ */
81
+ async function indexMessage(db, entry) {
82
+ await sql`delete from pithy_support_search where message_id = ${entry.messageId}`.execute(db);
83
+ await sql`
84
+ insert into pithy_support_search(thread_id, message_id, subject, body)
85
+ values (${entry.threadId}, ${entry.messageId}, ${entry.subject}, ${entry.body})
86
+ `.execute(db);
87
+ }
88
+ /**
89
+ * Rebuild the index for one thread — delete its rows, then re-add every message.
90
+ *
91
+ * The repair path for the failure mode application-side indexing has and triggers would not: a
92
+ * message stored while the index write failed. Also what a reindex after a tokenizer change runs.
93
+ */
94
+ async function reindexThread(db, threadId) {
95
+ await sql`delete from pithy_support_search where thread_id = ${threadId}`.execute(db);
96
+ const messages = await db.selectFrom(SUPPORT_MESSAGES_TABLE).select([
97
+ "id",
98
+ "threadId",
99
+ "subject",
100
+ "textBody"
101
+ ]).where("threadId", "=", threadId).execute();
102
+ for (const message of messages) await indexMessage(db, {
103
+ threadId: message.threadId,
104
+ messageId: message.id,
105
+ subject: message.subject,
106
+ body: message.textBody
107
+ });
108
+ return messages.length;
109
+ }
110
+ /**
111
+ * Rebuild the whole index from the messages table, in pages.
112
+ *
113
+ * **Creating the index is not the same as populating it**, and that gap is the one failure a search
114
+ * box must not have: an adopter who turns `search.fts` on after mail already exists gets an empty
115
+ * virtual table, and because the table now *exists* the `LIKE` fallback never fires — so the inbox
116
+ * answers "no matches" for a term that is demonstrably in the body. Silently returning nothing is
117
+ * worse than returning slowly, so provisioning backfills immediately after it creates.
118
+ *
119
+ * Paged on `(receivedAt, id)` rather than an offset, for the same reason the inbox is: rows arriving
120
+ * during a long backfill would shift an offset underneath it and skip messages.
121
+ */
122
+ async function reindexAll(db, options = {}) {
123
+ const pageSize = options.pageSize ?? 200;
124
+ await sql`delete from pithy_support_search`.execute(db);
125
+ let cursor;
126
+ let indexed = 0;
127
+ for (;;) {
128
+ let page = db.selectFrom(SUPPORT_MESSAGES_TABLE).select([
129
+ "id",
130
+ "threadId",
131
+ "subject",
132
+ "textBody",
133
+ "receivedAt"
134
+ ]).orderBy("receivedAt", "asc").orderBy("id", "asc").limit(pageSize);
135
+ if (cursor) {
136
+ const at = cursor.receivedAt;
137
+ const id = cursor.id;
138
+ page = page.where((eb) => eb.or([eb("receivedAt", ">", at), eb.and([eb("receivedAt", "=", at), eb("id", ">", id)])]));
139
+ }
140
+ const rows = await page.execute();
141
+ if (rows.length === 0) break;
142
+ for (const row of rows) {
143
+ await indexMessage(db, {
144
+ threadId: row.threadId,
145
+ messageId: row.id,
146
+ subject: row.subject,
147
+ body: row.textBody
148
+ });
149
+ indexed += 1;
150
+ }
151
+ options.onProgress?.(indexed);
152
+ const last = rows[rows.length - 1];
153
+ if (!last || rows.length < pageSize) break;
154
+ cursor = {
155
+ receivedAt: Number(last.receivedAt),
156
+ id: last.id
157
+ };
158
+ }
159
+ return indexed;
160
+ }
161
+ //#endregion
162
+ export { ftsQuery, indexMessage, isSearchable, reindexAll, reindexThread, searchPredicate };
@@ -0,0 +1,55 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { type Kysely } from "kysely";
4
+ import type { SupportDatabase } from "../data/tables";
5
+ /**
6
+ * The FTS5 index's lifecycle — created and dropped by `pithy support provision`, **not by a
7
+ * migration**.
8
+ *
9
+ * ## Why this is not a migration
10
+ *
11
+ * Because it holds no data. Every row in it is derived from `pithy_support_messages`, and
12
+ * `reindexThread` rebuilds it from there at any time — so dropping it loses a search box for as long
13
+ * as it takes to rebuild, and nothing else. That is the line: a migration is for schema whose loss
14
+ * loses data, and this is a provisioned resource like the R2 bucket and the routing rule, which
15
+ * support already creates the same way.
16
+ *
17
+ * Making it a migration was the mistake, and the failure was specific rather than theoretical.
18
+ * Composing it conditionally on `search.fts` meant turning the flag **off** removed an
19
+ * already-applied migration from the set, and Kysely reads a previously-executed migration that has
20
+ * vanished as corruption:
21
+ *
22
+ * corrupted migrations: previously executed migration 1200_support_0002_search is missing
23
+ *
24
+ * Every capability's migrations for a database compose into one provider, so that throw blocked
25
+ * `pithy migrate` for auth, payments, and email too — a whole-database outage from one capability's
26
+ * config flag, fixable only by knowing to roll back first. Out of the ledger, there is nothing to
27
+ * corrupt and toggling either way is just a re-provision.
28
+ *
29
+ * Both statements are idempotent, so provisioning stays safe to re-run — the property every other
30
+ * step of `pithy support provision` already has.
31
+ */
32
+ /** The virtual table's name. Snake_case because nothing translates it — every statement here is hand-written. */
33
+ export declare const SEARCH_TABLE = "pithy_support_search";
34
+ /**
35
+ * Create the full-text index if it is not already there.
36
+ *
37
+ * A standalone (not external-content) FTS5 table. External content would store the text once rather
38
+ * than twice, but it requires the indexed column names to exist verbatim on the content table, and
39
+ * ours is `text_body` rather than `body`. Duplicating is the honest trade for an index that cannot
40
+ * desynchronize on a column rename.
41
+ *
42
+ * `thread_id` and `message_id` are UNINDEXED: they are carried so a match resolves straight to a
43
+ * conversation and so one message's rows can be replaced, and tokenizing a UUID would only add noise
44
+ * to every query.
45
+ */
46
+ export declare function createSearchIndex(db: SupportDatabase | Kysely<unknown>): Promise<void>;
47
+ /**
48
+ * Drop the full-text index if it is there.
49
+ *
50
+ * Safe by construction: the index is derived, so this costs a rebuild rather than a restore. It is
51
+ * what `pithy support provision` runs when `search.fts` goes back to false — and the reason turning
52
+ * the feature off is now a one-command operation instead of a migration rollback.
53
+ */
54
+ export declare function dropSearchIndex(db: SupportDatabase | Kysely<unknown>): Promise<void>;
55
+ //# sourceMappingURL=searchIndex.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,68 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { sql } from "kysely";
4
+ //#region src/store/searchIndex.ts
5
+ /**
6
+ * The FTS5 index's lifecycle — created and dropped by `pithy support provision`, **not by a
7
+ * migration**.
8
+ *
9
+ * ## Why this is not a migration
10
+ *
11
+ * Because it holds no data. Every row in it is derived from `pithy_support_messages`, and
12
+ * `reindexThread` rebuilds it from there at any time — so dropping it loses a search box for as long
13
+ * as it takes to rebuild, and nothing else. That is the line: a migration is for schema whose loss
14
+ * loses data, and this is a provisioned resource like the R2 bucket and the routing rule, which
15
+ * support already creates the same way.
16
+ *
17
+ * Making it a migration was the mistake, and the failure was specific rather than theoretical.
18
+ * Composing it conditionally on `search.fts` meant turning the flag **off** removed an
19
+ * already-applied migration from the set, and Kysely reads a previously-executed migration that has
20
+ * vanished as corruption:
21
+ *
22
+ * corrupted migrations: previously executed migration 1200_support_0002_search is missing
23
+ *
24
+ * Every capability's migrations for a database compose into one provider, so that throw blocked
25
+ * `pithy migrate` for auth, payments, and email too — a whole-database outage from one capability's
26
+ * config flag, fixable only by knowing to roll back first. Out of the ledger, there is nothing to
27
+ * corrupt and toggling either way is just a re-provision.
28
+ *
29
+ * Both statements are idempotent, so provisioning stays safe to re-run — the property every other
30
+ * step of `pithy support provision` already has.
31
+ */
32
+ /** The virtual table's name. Snake_case because nothing translates it — every statement here is hand-written. */
33
+ const SEARCH_TABLE = "pithy_support_search";
34
+ /**
35
+ * Create the full-text index if it is not already there.
36
+ *
37
+ * A standalone (not external-content) FTS5 table. External content would store the text once rather
38
+ * than twice, but it requires the indexed column names to exist verbatim on the content table, and
39
+ * ours is `text_body` rather than `body`. Duplicating is the honest trade for an index that cannot
40
+ * desynchronize on a column rename.
41
+ *
42
+ * `thread_id` and `message_id` are UNINDEXED: they are carried so a match resolves straight to a
43
+ * conversation and so one message's rows can be replaced, and tokenizing a UUID would only add noise
44
+ * to every query.
45
+ */
46
+ async function createSearchIndex(db) {
47
+ await sql`
48
+ create virtual table if not exists pithy_support_search using fts5(
49
+ thread_id unindexed,
50
+ message_id unindexed,
51
+ subject,
52
+ body,
53
+ tokenize = 'unicode61 remove_diacritics 2'
54
+ )
55
+ `.execute(db);
56
+ }
57
+ /**
58
+ * Drop the full-text index if it is there.
59
+ *
60
+ * Safe by construction: the index is derived, so this costs a rebuild rather than a restore. It is
61
+ * what `pithy support provision` runs when `search.fts` goes back to false — and the reason turning
62
+ * the feature off is now a one-command operation instead of a migration rollback.
63
+ */
64
+ async function dropSearchIndex(db) {
65
+ await sql`drop table if exists pithy_support_search`.execute(db);
66
+ }
67
+ //#endregion
68
+ export { SEARCH_TABLE, createSearchIndex, dropSearchIndex };
@@ -0,0 +1,149 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { Logger } from "@pithy-sh/core/src/logger/logger";
4
+ import { SupportAttachment } from "../data/attachment";
5
+ import type { SupportChannel, SupportPriority, SupportSentiment } from "../data/enums";
6
+ import { SupportMessage } from "../data/message";
7
+ import { type SupportDatabase } from "../data/tables";
8
+ import { SupportThread } from "../data/thread";
9
+ /**
10
+ * The inbox query — the one read this whole data model was shaped around.
11
+ *
12
+ * ## Cursor pagination, never offset
13
+ *
14
+ * `OFFSET` is wrong here for a reason specific to an inbox: rows are inserted at the *front* of the
15
+ * order the list is sorted by, so every message that arrives while somebody is reading shifts every
16
+ * subsequent page down by one. The reader sees a thread twice and misses another, and the misses are
17
+ * silent. A cursor on `(lastMessageAt, id)` describes a position in the data rather than a count of
18
+ * rows skipped, so new mail arriving above it changes nothing about what comes next.
19
+ *
20
+ * `id` is in the cursor because `lastMessageAt` is not unique — two messages landing in the same
21
+ * millisecond is unlikely and a page boundary landing between them is exactly where it would show up
22
+ * as a dropped row.
23
+ */
24
+ /** An opaque position in the inbox ordering. */
25
+ export interface ThreadCursor {
26
+ /** The `lastMessageAt` of the last thread on the previous page, as ms-epoch. */
27
+ lastMessageAt: number;
28
+ /** That thread's id — the tiebreak that makes the position exact. */
29
+ id: string;
30
+ }
31
+ /**
32
+ * Encode a cursor for the wire.
33
+ *
34
+ * Base64url over JSON, and **opaque by intent rather than by obscurity**: the point is that a client
35
+ * cannot construct one by hand and therefore cannot come to depend on its shape, so the ordering can
36
+ * change later without breaking every caller. It is not a secret — it holds a timestamp and an id
37
+ * the caller was just given.
38
+ */
39
+ export declare function encodeCursor(cursor: ThreadCursor): string;
40
+ /** Decode a cursor. Returns undefined for anything malformed — a bad cursor is a first page, not a 500. */
41
+ export declare function decodeCursor(value: string | undefined): ThreadCursor | undefined;
42
+ /** What a caller may filter the inbox by. */
43
+ export interface ListThreadsQuery {
44
+ /** Open threads, done threads, or both. Defaults to open. */
45
+ archived?: boolean;
46
+ /** One category key from the effective taxonomy — what the classifier decided. */
47
+ category?: string;
48
+ /** One category key from the effective taxonomy — what the submitter said. A different question. */
49
+ declaredCategory?: string;
50
+ /** One priority. */
51
+ priority?: SupportPriority;
52
+ /** One sentiment. */
53
+ sentiment?: SupportSentiment;
54
+ /** One channel — mail, or what signed-in users filed from inside the app. */
55
+ channel?: SupportChannel;
56
+ /** Which inbox address, for a Worker serving more than one. */
57
+ inbox?: string;
58
+ /** Free text over subjects and bodies. */
59
+ q?: string;
60
+ /** Where to resume from. */
61
+ cursor?: string;
62
+ /** How many to return. */
63
+ limit?: number;
64
+ }
65
+ /** A thread as the inbox lists it — the row, plus this viewer's own private state. */
66
+ export interface ListedThread extends SupportThread {
67
+ /** Whether this viewer has read it. False when they have no flag row, which is the common case. */
68
+ read: boolean;
69
+ /** When this viewer's snooze expires, or null. */
70
+ snoozedUntil: Date | null;
71
+ }
72
+ /** One page of the inbox. */
73
+ export interface ThreadPage {
74
+ /** The threads, newest first. */
75
+ threads: ListedThread[];
76
+ /** The cursor for the next page, or null when this was the last one. */
77
+ nextCursor: string | null;
78
+ }
79
+ /**
80
+ * List threads, as one viewer sees them.
81
+ *
82
+ * The viewer is a parameter rather than an afterthought because the per-viewer flags are **read
83
+ * here**: a snooze that was stored and never consulted is a button that does nothing, and `read` that
84
+ * is never projected is a column a dashboard cannot render. Both are left-joined, so a thread with no
85
+ * flag row for this viewer — the overwhelming majority — still appears, unread and unsnoozed.
86
+ */
87
+ export declare function listThreads(db: SupportDatabase, query: ListThreadsQuery, options: {
88
+ fts: boolean;
89
+ viewer: string;
90
+ now: Date;
91
+ log?: Logger;
92
+ }): Promise<ThreadPage>;
93
+ /** A thread with everything a reading pane shows. */
94
+ export interface ThreadDetail {
95
+ /** The thread row. */
96
+ thread: SupportThread;
97
+ /** Its messages, oldest first — a conversation reads downward. */
98
+ messages: SupportMessage[];
99
+ /** Its attachments, across every message. */
100
+ attachments: SupportAttachment[];
101
+ }
102
+ /** Read one thread in full. Throws `support/not_found` when it does not exist. */
103
+ export declare function readThread(db: SupportDatabase, threadId: string): Promise<ThreadDetail>;
104
+ /**
105
+ * List one account's own in-app conversations.
106
+ *
107
+ * **Two conditions on the `where`, and the second is not redundant.** `userId` alone would also return
108
+ * every *email* thread this capability linked to the account — and that link was matched against an
109
+ * address in a header nobody proved, so it names an account the sender merely claimed to be. Serving
110
+ * those to whoever currently holds the address would turn the mail path's known weakness into a read
111
+ * primitive, which is precisely the trade this channel exists to avoid making.
112
+ *
113
+ * Archived threads are included, unlike the operator inbox. An inbox hides done threads because it is a
114
+ * work queue; a person looking at their own requests is looking for the one that was answered.
115
+ */
116
+ export declare function listOwnThreads(db: SupportDatabase, userId: string, query: {
117
+ cursor?: string;
118
+ limit?: number;
119
+ }): Promise<{
120
+ threads: SupportThread[];
121
+ nextCursor: string | null;
122
+ }>;
123
+ /**
124
+ * Read one of an account's own in-app conversations.
125
+ *
126
+ * **A 404 for somebody else's thread, never a 403.** The two are the same answer on purpose: a 403
127
+ * confirms the id names a real conversation, and on an inbox of other people's correspondence that
128
+ * confirmation is itself the disclosure. The scoping is in the `where`, not in a check after the read,
129
+ * so there is no version of this that fetches the row first and forgets to compare.
130
+ */
131
+ export declare function readOwnThread(db: SupportDatabase, threadId: string, userId: string): Promise<ThreadDetail>;
132
+ /**
133
+ * Mark a thread done, or reopen it.
134
+ *
135
+ * The one shared piece of state in the model, and the write is unconditional rather than a
136
+ * compare-and-set: archiving something already archived is not a conflict, it is the same outcome,
137
+ * and a support console that threw on a double-click would be worse than one that did nothing.
138
+ */
139
+ export declare function setArchived(db: SupportDatabase, threadId: string, archived: boolean, viewer: string, now: Date): Promise<SupportThread>;
140
+ /** Set one viewer's private flags on a thread. Upserts on `(threadId, viewer)`. */
141
+ export declare function setFlags(db: SupportDatabase, options: {
142
+ threadId: string;
143
+ viewer: string;
144
+ read?: boolean;
145
+ snoozedUntil?: Date | null;
146
+ newId: () => string;
147
+ now: Date;
148
+ }): Promise<void>;
149
+ //# sourceMappingURL=threads.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"threads.d.ts","sourceRoot":"","sources":["../../src/store/threads.ts"],"names":[],"mappings":"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"}