@pithy-sh/support 0.1.1 → 0.1.3

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 (167) hide show
  1. package/dist/ai/classify.d.ts +90 -0
  2. package/dist/ai/classify.d.ts.map +1 -0
  3. package/dist/ai/classify.js +151 -0
  4. package/dist/attachment/store.d.ts +53 -0
  5. package/dist/attachment/store.d.ts.map +1 -0
  6. package/dist/attachment/store.js +67 -0
  7. package/dist/audit/actions.d.ts +68 -0
  8. package/dist/audit/actions.d.ts.map +1 -0
  9. package/dist/audit/actions.js +68 -0
  10. package/dist/capability.d.ts +93 -0
  11. package/dist/capability.d.ts.map +1 -0
  12. package/dist/capability.js +187 -0
  13. package/dist/client/projection.d.ts +56 -0
  14. package/dist/client/projection.d.ts.map +1 -0
  15. package/dist/client/projection.js +0 -0
  16. package/dist/config/config.d.ts +215 -0
  17. package/dist/config/config.d.ts.map +1 -0
  18. package/dist/config/config.js +169 -0
  19. package/dist/data/attachment.d.ts +30 -0
  20. package/dist/data/attachment.d.ts.map +1 -0
  21. package/dist/data/attachment.js +32 -0
  22. package/dist/data/billingScope.d.ts +30 -0
  23. package/dist/data/billingScope.d.ts.map +1 -0
  24. package/dist/data/billingScope.js +32 -0
  25. package/dist/data/categories.d.ts +54 -0
  26. package/dist/data/categories.d.ts.map +1 -0
  27. package/dist/data/categories.js +97 -0
  28. package/dist/data/classification.d.ts +34 -0
  29. package/dist/data/classification.d.ts.map +1 -0
  30. package/dist/data/classification.js +28 -0
  31. package/dist/data/enums.d.ts +82 -0
  32. package/dist/data/enums.d.ts.map +1 -0
  33. package/dist/data/enums.js +70 -0
  34. package/dist/data/flag.d.ts +25 -0
  35. package/dist/data/flag.d.ts.map +1 -0
  36. package/dist/data/flag.js +27 -0
  37. package/dist/data/message.d.ts +81 -0
  38. package/dist/data/message.d.ts.map +1 -0
  39. package/dist/data/message.js +84 -0
  40. package/dist/data/tables.d.ts +160 -0
  41. package/dist/data/tables.d.ts.map +1 -0
  42. package/dist/data/tables.js +44 -0
  43. package/dist/data/thread.d.ts +77 -0
  44. package/dist/data/thread.d.ts.map +1 -0
  45. package/dist/data/thread.js +65 -0
  46. package/dist/error/errors.d.ts +64 -0
  47. package/dist/error/errors.d.ts.map +1 -0
  48. package/dist/error/errors.js +82 -0
  49. package/dist/http/guards.d.ts +44 -0
  50. package/dist/http/guards.d.ts.map +1 -0
  51. package/dist/http/guards.js +53 -0
  52. package/dist/http/handlers.d.ts +89 -0
  53. package/dist/http/handlers.d.ts.map +1 -0
  54. package/dist/http/handlers.js +248 -0
  55. package/dist/http/resolve.d.ts +44 -0
  56. package/dist/http/resolve.d.ts.map +1 -0
  57. package/dist/http/resolve.js +72 -0
  58. package/dist/http/responses.d.ts +829 -0
  59. package/dist/http/responses.d.ts.map +1 -0
  60. package/dist/http/responses.js +303 -0
  61. package/dist/http/routes.d.ts +109 -0
  62. package/dist/http/routes.d.ts.map +1 -0
  63. package/dist/http/routes.js +62 -0
  64. package/dist/http/schemas.d.ts +121 -0
  65. package/dist/http/schemas.d.ts.map +1 -0
  66. package/dist/http/schemas.js +89 -0
  67. package/dist/http/scopes.d.ts +56 -0
  68. package/dist/http/scopes.d.ts.map +1 -0
  69. package/dist/http/scopes.js +107 -0
  70. package/dist/http/views.d.ts +36 -0
  71. package/dist/http/views.d.ts.map +1 -0
  72. package/dist/http/views.js +144 -0
  73. package/dist/inbound/authenticity.d.ts +81 -0
  74. package/dist/inbound/authenticity.d.ts.map +1 -0
  75. package/dist/inbound/authenticity.js +61 -0
  76. package/dist/inbound/guard.d.ts +74 -0
  77. package/dist/inbound/guard.d.ts.map +1 -0
  78. package/dist/inbound/guard.js +78 -0
  79. package/dist/inbound/handler.d.ts +28 -0
  80. package/dist/inbound/handler.d.ts.map +1 -0
  81. package/dist/inbound/handler.js +82 -0
  82. package/dist/inbound/ingest.d.ts +104 -0
  83. package/dist/inbound/ingest.d.ts.map +1 -0
  84. package/dist/inbound/ingest.js +326 -0
  85. package/dist/inbound/recipient.d.ts +16 -0
  86. package/dist/inbound/recipient.d.ts.map +1 -0
  87. package/dist/inbound/recipient.js +46 -0
  88. package/dist/index.d.ts +20 -0
  89. package/dist/index.d.ts.map +1 -0
  90. package/dist/index.js +14 -0
  91. package/dist/link/sender.d.ts +160 -0
  92. package/dist/link/sender.d.ts.map +1 -0
  93. package/dist/link/sender.js +200 -0
  94. package/dist/migrations/0001_threads.d.ts +26 -0
  95. package/dist/migrations/0001_threads.d.ts.map +1 -0
  96. package/dist/migrations/0001_threads.js +103 -0
  97. package/dist/mime/address.d.ts +22 -0
  98. package/dist/mime/address.d.ts.map +1 -0
  99. package/dist/mime/address.js +28 -0
  100. package/dist/mime/parse.d.ts +96 -0
  101. package/dist/mime/parse.d.ts.map +1 -0
  102. package/dist/mime/parse.js +146 -0
  103. package/dist/mime/sanitize.d.ts +25 -0
  104. package/dist/mime/sanitize.d.ts.map +1 -0
  105. package/dist/mime/sanitize.js +212 -0
  106. package/dist/mime/threading.d.ts +79 -0
  107. package/dist/mime/threading.d.ts.map +1 -0
  108. package/dist/mime/threading.js +111 -0
  109. package/dist/mime/truncate.d.ts +17 -0
  110. package/dist/mime/truncate.d.ts.map +1 -0
  111. package/dist/mime/truncate.js +47 -0
  112. package/dist/provision/provisionSupport.d.ts +158 -0
  113. package/dist/provision/provisionSupport.d.ts.map +1 -0
  114. package/dist/provision/provisionSupport.js +110 -0
  115. package/dist/provision/resolveSupportConfig.d.ts +45 -0
  116. package/dist/provision/resolveSupportConfig.d.ts.map +1 -0
  117. package/dist/provision/resolveSupportConfig.js +22 -0
  118. package/dist/reply/send.d.ts +123 -0
  119. package/dist/reply/send.d.ts.map +1 -0
  120. package/dist/reply/send.js +153 -0
  121. package/dist/reply/snippets.d.ts +79 -0
  122. package/dist/reply/snippets.d.ts.map +1 -0
  123. package/dist/reply/snippets.js +135 -0
  124. package/dist/secret/registry.d.ts +18 -0
  125. package/dist/secret/registry.d.ts.map +1 -0
  126. package/dist/secret/registry.js +21 -0
  127. package/dist/seeds/example.d.ts +3 -0
  128. package/dist/seeds/example.d.ts.map +1 -0
  129. package/dist/seeds/example.js +350 -0
  130. package/dist/store/paging.d.ts +18 -0
  131. package/dist/store/paging.d.ts.map +1 -0
  132. package/dist/store/paging.js +20 -0
  133. package/dist/store/search.d.ts +67 -0
  134. package/dist/store/search.d.ts.map +1 -0
  135. package/dist/store/search.js +160 -0
  136. package/dist/store/searchIndex.d.ts +53 -0
  137. package/dist/store/searchIndex.d.ts.map +1 -0
  138. package/dist/store/searchIndex.js +66 -0
  139. package/dist/store/threads.d.ts +147 -0
  140. package/dist/store/threads.d.ts.map +1 -0
  141. package/dist/store/threads.js +197 -0
  142. package/dist/submission/encoding.d.ts +21 -0
  143. package/dist/submission/encoding.d.ts.map +1 -0
  144. package/dist/submission/encoding.js +55 -0
  145. package/dist/submission/guard.d.ts +71 -0
  146. package/dist/submission/guard.d.ts.map +1 -0
  147. package/dist/submission/guard.js +46 -0
  148. package/dist/submission/submit.d.ts +152 -0
  149. package/dist/submission/submit.d.ts.map +1 -0
  150. package/dist/submission/submit.js +319 -0
  151. package/dist/version.generated.d.ts +5 -0
  152. package/dist/version.generated.d.ts.map +1 -0
  153. package/dist/version.generated.js +7 -0
  154. package/dist/workflows/classify.d.ts +63 -0
  155. package/dist/workflows/classify.d.ts.map +1 -0
  156. package/dist/workflows/classify.js +68 -0
  157. package/dist/workflows/retryPolicy.d.ts +39 -0
  158. package/dist/workflows/retryPolicy.d.ts.map +1 -0
  159. package/dist/workflows/retryPolicy.js +43 -0
  160. package/dist/workflows/specs.d.ts +43 -0
  161. package/dist/workflows/specs.d.ts.map +1 -0
  162. package/dist/workflows/specs.js +46 -0
  163. package/dist/workflows/worker.d.ts +48 -0
  164. package/dist/workflows/worker.d.ts.map +1 -0
  165. package/dist/workflows/worker.js +37 -0
  166. package/package.json +24 -15
  167. package/src/version.generated.ts +1 -1
@@ -0,0 +1,160 @@
1
+ import { SUPPORT_MESSAGES_TABLE } from "../data/tables.js";
2
+ import { sql } from "kysely";
3
+ //#region src/store/search.ts
4
+ /**
5
+ * Text search over the inbox — two backends behind one predicate.
6
+ *
7
+ * **`LIKE` is the default and FTS5 is the opt-in**, which is the opposite of what it looks like it
8
+ * should be. The reason is in `store/searchIndex.ts`: an FTS5 virtual table anywhere in a D1
9
+ * database makes `wrangler d1 export` refuse to dump *the whole database*, so turning it on by
10
+ * default would quietly cost every adopter their backup story for tables that have nothing to do
11
+ * with support. `LIKE` is an unindexed scan and is completely adequate for an inbox measured in
12
+ * thousands of messages, which is what this capability is for.
13
+ *
14
+ * Both paths return a **predicate over `pithy_support_threads`**, not a list of ids, so search
15
+ * composes with the category/archived filters and — crucially — with cursor pagination. Returning
16
+ * ids would mean either capping the match set (silent truncation, which reads as "that is all there
17
+ * is") or paginating twice over.
18
+ *
19
+ * Either way this is keyword search, deliberately. People search a support inbox for a word they
20
+ * remember — an order number, an error string, a surname — not for a concept. `@pithy-sh/vector` is
21
+ * there for anyone who wants meaning-matching, and it is the nicer demo and the wrong default: it
22
+ * cannot find `ORD-40912`.
23
+ */
24
+ /** The longest search term accepted. Bounded because it becomes a scan predicate. */
25
+ const MAX_TERM = 200;
26
+ /** Escape the `LIKE` wildcards so a term containing `%` matches a literal `%`. */
27
+ function escapeLike(term) {
28
+ return term.replace(/[\\%_]/g, (char) => `\\${char}`);
29
+ }
30
+ /**
31
+ * Turn a user's words into an FTS5 MATCH expression.
32
+ *
33
+ * Every token is quoted and the whole thing is ANDed. Quoting is what stops FTS5's own query syntax
34
+ * from leaking through: a bare `NOT`, `OR`, `*` or `(` is an operator to FTS5, so a customer whose
35
+ * surname is a keyword — or anyone deliberately probing — would otherwise change the shape of the
36
+ * query rather than search for a word. This is the same "input is data, never syntax" rule the rest
37
+ * of the package applies to model output and to HTML.
38
+ */
39
+ function ftsQuery(term) {
40
+ 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 ");
41
+ }
42
+ /** Whether a term has anything searchable left after tokenizing. */
43
+ function isSearchable(term, fts) {
44
+ return fts ? ftsQuery(term).length > 0 : term.trim().length > 0;
45
+ }
46
+ /**
47
+ * A predicate matching threads whose messages contain `term`.
48
+ *
49
+ * Correlated on the thread id so it drops into a `where` beside every other filter. The subquery is
50
+ * `exists`, so it stops at the first matching message rather than counting them.
51
+ */
52
+ function searchPredicate(term, options) {
53
+ if (options.fts) return sql`exists (
54
+ select 1 from pithy_support_search
55
+ where pithy_support_search.thread_id = pithy_support_threads.id
56
+ and pithy_support_search match ${ftsQuery(term)}
57
+ )`;
58
+ const pattern = `%${escapeLike(term.slice(0, MAX_TERM))}%`;
59
+ return sql`exists (
60
+ select 1 from pithy_support_messages m
61
+ where m.thread_id = pithy_support_threads.id
62
+ and (m.subject like ${pattern} escape '\\' or m.text_body like ${pattern} escape '\\')
63
+ )`;
64
+ }
65
+ /**
66
+ * Add a message to the full-text index, replacing any row already there for it.
67
+ *
68
+ * **Remove-then-insert, not a bare insert** — the same shape the CMS `syncFts` uses, and for the
69
+ * reason application-managed indexes always need it: an FTS5 table has no primary key and no unique
70
+ * constraint to lean on, so a second call for the same message would silently add a second copy and
71
+ * the thread would start appearing twice in its own search results. Making the write idempotent means
72
+ * a retry, a reindex, and a first index are all the same operation, which is the property every other
73
+ * write path in this repo is built on.
74
+ *
75
+ * Best-effort by contract: a failed index write must never lose a customer's message, so the caller
76
+ * logs and carries on — the row is still there, still readable, still repairable by
77
+ * {@link reindexThread}, and only missing from one search box.
78
+ */
79
+ async function indexMessage(db, entry) {
80
+ await sql`delete from pithy_support_search where message_id = ${entry.messageId}`.execute(db);
81
+ await sql`
82
+ insert into pithy_support_search(thread_id, message_id, subject, body)
83
+ values (${entry.threadId}, ${entry.messageId}, ${entry.subject}, ${entry.body})
84
+ `.execute(db);
85
+ }
86
+ /**
87
+ * Rebuild the index for one thread — delete its rows, then re-add every message.
88
+ *
89
+ * The repair path for the failure mode application-side indexing has and triggers would not: a
90
+ * message stored while the index write failed. Also what a reindex after a tokenizer change runs.
91
+ */
92
+ async function reindexThread(db, threadId) {
93
+ await sql`delete from pithy_support_search where thread_id = ${threadId}`.execute(db);
94
+ const messages = await db.selectFrom(SUPPORT_MESSAGES_TABLE).select([
95
+ "id",
96
+ "threadId",
97
+ "subject",
98
+ "textBody"
99
+ ]).where("threadId", "=", threadId).execute();
100
+ for (const message of messages) await indexMessage(db, {
101
+ threadId: message.threadId,
102
+ messageId: message.id,
103
+ subject: message.subject,
104
+ body: message.textBody
105
+ });
106
+ return messages.length;
107
+ }
108
+ /**
109
+ * Rebuild the whole index from the messages table, in pages.
110
+ *
111
+ * **Creating the index is not the same as populating it**, and that gap is the one failure a search
112
+ * box must not have: an adopter who turns `search.fts` on after mail already exists gets an empty
113
+ * virtual table, and because the table now *exists* the `LIKE` fallback never fires — so the inbox
114
+ * answers "no matches" for a term that is demonstrably in the body. Silently returning nothing is
115
+ * worse than returning slowly, so provisioning backfills immediately after it creates.
116
+ *
117
+ * Paged on `(receivedAt, id)` rather than an offset, for the same reason the inbox is: rows arriving
118
+ * during a long backfill would shift an offset underneath it and skip messages.
119
+ */
120
+ async function reindexAll(db, options = {}) {
121
+ const pageSize = options.pageSize ?? 200;
122
+ await sql`delete from pithy_support_search`.execute(db);
123
+ let cursor;
124
+ let indexed = 0;
125
+ for (;;) {
126
+ let page = db.selectFrom(SUPPORT_MESSAGES_TABLE).select([
127
+ "id",
128
+ "threadId",
129
+ "subject",
130
+ "textBody",
131
+ "receivedAt"
132
+ ]).orderBy("receivedAt", "asc").orderBy("id", "asc").limit(pageSize);
133
+ if (cursor) {
134
+ const at = cursor.receivedAt;
135
+ const id = cursor.id;
136
+ page = page.where((eb) => eb.or([eb("receivedAt", ">", at), eb.and([eb("receivedAt", "=", at), eb("id", ">", id)])]));
137
+ }
138
+ const rows = await page.execute();
139
+ if (rows.length === 0) break;
140
+ for (const row of rows) {
141
+ await indexMessage(db, {
142
+ threadId: row.threadId,
143
+ messageId: row.id,
144
+ subject: row.subject,
145
+ body: row.textBody
146
+ });
147
+ indexed += 1;
148
+ }
149
+ options.onProgress?.(indexed);
150
+ const last = rows[rows.length - 1];
151
+ if (!last || rows.length < pageSize) break;
152
+ cursor = {
153
+ receivedAt: Number(last.receivedAt),
154
+ id: last.id
155
+ };
156
+ }
157
+ return indexed;
158
+ }
159
+ //#endregion
160
+ export { ftsQuery, indexMessage, isSearchable, reindexAll, reindexThread, searchPredicate };
@@ -0,0 +1,53 @@
1
+ import { type Kysely } from "kysely";
2
+ import type { SupportDatabase } from "../data/tables";
3
+ /**
4
+ * The FTS5 index's lifecycle — created and dropped by `pithy support provision`, **not by a
5
+ * migration**.
6
+ *
7
+ * ## Why this is not a migration
8
+ *
9
+ * Because it holds no data. Every row in it is derived from `pithy_support_messages`, and
10
+ * `reindexThread` rebuilds it from there at any time — so dropping it loses a search box for as long
11
+ * as it takes to rebuild, and nothing else. That is the line: a migration is for schema whose loss
12
+ * loses data, and this is a provisioned resource like the R2 bucket and the routing rule, which
13
+ * support already creates the same way.
14
+ *
15
+ * Making it a migration was the mistake, and the failure was specific rather than theoretical.
16
+ * Composing it conditionally on `search.fts` meant turning the flag **off** removed an
17
+ * already-applied migration from the set, and Kysely reads a previously-executed migration that has
18
+ * vanished as corruption:
19
+ *
20
+ * corrupted migrations: previously executed migration 1200_support_0002_search is missing
21
+ *
22
+ * Every capability's migrations for a database compose into one provider, so that throw blocked
23
+ * `pithy migrate` for auth, payments, and email too — a whole-database outage from one capability's
24
+ * config flag, fixable only by knowing to roll back first. Out of the ledger, there is nothing to
25
+ * corrupt and toggling either way is just a re-provision.
26
+ *
27
+ * Both statements are idempotent, so provisioning stays safe to re-run — the property every other
28
+ * step of `pithy support provision` already has.
29
+ */
30
+ /** The virtual table's name. Snake_case because nothing translates it — every statement here is hand-written. */
31
+ export declare const SEARCH_TABLE = "pithy_support_search";
32
+ /**
33
+ * Create the full-text index if it is not already there.
34
+ *
35
+ * A standalone (not external-content) FTS5 table. External content would store the text once rather
36
+ * than twice, but it requires the indexed column names to exist verbatim on the content table, and
37
+ * ours is `text_body` rather than `body`. Duplicating is the honest trade for an index that cannot
38
+ * desynchronize on a column rename.
39
+ *
40
+ * `thread_id` and `message_id` are UNINDEXED: they are carried so a match resolves straight to a
41
+ * conversation and so one message's rows can be replaced, and tokenizing a UUID would only add noise
42
+ * to every query.
43
+ */
44
+ export declare function createSearchIndex(db: SupportDatabase | Kysely<unknown>): Promise<void>;
45
+ /**
46
+ * Drop the full-text index if it is there.
47
+ *
48
+ * Safe by construction: the index is derived, so this costs a rebuild rather than a restore. It is
49
+ * what `pithy support provision` runs when `search.fts` goes back to false — and the reason turning
50
+ * the feature off is now a one-command operation instead of a migration rollback.
51
+ */
52
+ export declare function dropSearchIndex(db: SupportDatabase | Kysely<unknown>): Promise<void>;
53
+ //# 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,66 @@
1
+ import { sql } from "kysely";
2
+ //#region src/store/searchIndex.ts
3
+ /**
4
+ * The FTS5 index's lifecycle — created and dropped by `pithy support provision`, **not by a
5
+ * migration**.
6
+ *
7
+ * ## Why this is not a migration
8
+ *
9
+ * Because it holds no data. Every row in it is derived from `pithy_support_messages`, and
10
+ * `reindexThread` rebuilds it from there at any time — so dropping it loses a search box for as long
11
+ * as it takes to rebuild, and nothing else. That is the line: a migration is for schema whose loss
12
+ * loses data, and this is a provisioned resource like the R2 bucket and the routing rule, which
13
+ * support already creates the same way.
14
+ *
15
+ * Making it a migration was the mistake, and the failure was specific rather than theoretical.
16
+ * Composing it conditionally on `search.fts` meant turning the flag **off** removed an
17
+ * already-applied migration from the set, and Kysely reads a previously-executed migration that has
18
+ * vanished as corruption:
19
+ *
20
+ * corrupted migrations: previously executed migration 1200_support_0002_search is missing
21
+ *
22
+ * Every capability's migrations for a database compose into one provider, so that throw blocked
23
+ * `pithy migrate` for auth, payments, and email too — a whole-database outage from one capability's
24
+ * config flag, fixable only by knowing to roll back first. Out of the ledger, there is nothing to
25
+ * corrupt and toggling either way is just a re-provision.
26
+ *
27
+ * Both statements are idempotent, so provisioning stays safe to re-run — the property every other
28
+ * step of `pithy support provision` already has.
29
+ */
30
+ /** The virtual table's name. Snake_case because nothing translates it — every statement here is hand-written. */
31
+ const SEARCH_TABLE = "pithy_support_search";
32
+ /**
33
+ * Create the full-text index if it is not already there.
34
+ *
35
+ * A standalone (not external-content) FTS5 table. External content would store the text once rather
36
+ * than twice, but it requires the indexed column names to exist verbatim on the content table, and
37
+ * ours is `text_body` rather than `body`. Duplicating is the honest trade for an index that cannot
38
+ * desynchronize on a column rename.
39
+ *
40
+ * `thread_id` and `message_id` are UNINDEXED: they are carried so a match resolves straight to a
41
+ * conversation and so one message's rows can be replaced, and tokenizing a UUID would only add noise
42
+ * to every query.
43
+ */
44
+ async function createSearchIndex(db) {
45
+ await sql`
46
+ create virtual table if not exists pithy_support_search using fts5(
47
+ thread_id unindexed,
48
+ message_id unindexed,
49
+ subject,
50
+ body,
51
+ tokenize = 'unicode61 remove_diacritics 2'
52
+ )
53
+ `.execute(db);
54
+ }
55
+ /**
56
+ * Drop the full-text index if it is there.
57
+ *
58
+ * Safe by construction: the index is derived, so this costs a rebuild rather than a restore. It is
59
+ * what `pithy support provision` runs when `search.fts` goes back to false — and the reason turning
60
+ * the feature off is now a one-command operation instead of a migration rollback.
61
+ */
62
+ async function dropSearchIndex(db) {
63
+ await sql`drop table if exists pithy_support_search`.execute(db);
64
+ }
65
+ //#endregion
66
+ export { SEARCH_TABLE, createSearchIndex, dropSearchIndex };
@@ -0,0 +1,147 @@
1
+ import type { Logger } from "@pithy-sh/core/src/logger/logger";
2
+ import { SupportAttachment } from "../data/attachment";
3
+ import type { SupportChannel, SupportPriority, SupportSentiment } from "../data/enums";
4
+ import { SupportMessage } from "../data/message";
5
+ import { type SupportDatabase } from "../data/tables";
6
+ import { SupportThread } from "../data/thread";
7
+ /**
8
+ * The inbox query — the one read this whole data model was shaped around.
9
+ *
10
+ * ## Cursor pagination, never offset
11
+ *
12
+ * `OFFSET` is wrong here for a reason specific to an inbox: rows are inserted at the *front* of the
13
+ * order the list is sorted by, so every message that arrives while somebody is reading shifts every
14
+ * subsequent page down by one. The reader sees a thread twice and misses another, and the misses are
15
+ * silent. A cursor on `(lastMessageAt, id)` describes a position in the data rather than a count of
16
+ * rows skipped, so new mail arriving above it changes nothing about what comes next.
17
+ *
18
+ * `id` is in the cursor because `lastMessageAt` is not unique — two messages landing in the same
19
+ * millisecond is unlikely and a page boundary landing between them is exactly where it would show up
20
+ * as a dropped row.
21
+ */
22
+ /** An opaque position in the inbox ordering. */
23
+ export interface ThreadCursor {
24
+ /** The `lastMessageAt` of the last thread on the previous page, as ms-epoch. */
25
+ lastMessageAt: number;
26
+ /** That thread's id — the tiebreak that makes the position exact. */
27
+ id: string;
28
+ }
29
+ /**
30
+ * Encode a cursor for the wire.
31
+ *
32
+ * Base64url over JSON, and **opaque by intent rather than by obscurity**: the point is that a client
33
+ * cannot construct one by hand and therefore cannot come to depend on its shape, so the ordering can
34
+ * change later without breaking every caller. It is not a secret — it holds a timestamp and an id
35
+ * the caller was just given.
36
+ */
37
+ export declare function encodeCursor(cursor: ThreadCursor): string;
38
+ /** Decode a cursor. Returns undefined for anything malformed — a bad cursor is a first page, not a 500. */
39
+ export declare function decodeCursor(value: string | undefined): ThreadCursor | undefined;
40
+ /** What a caller may filter the inbox by. */
41
+ export interface ListThreadsQuery {
42
+ /** Open threads, done threads, or both. Defaults to open. */
43
+ archived?: boolean;
44
+ /** One category key from the effective taxonomy — what the classifier decided. */
45
+ category?: string;
46
+ /** One category key from the effective taxonomy — what the submitter said. A different question. */
47
+ declaredCategory?: string;
48
+ /** One priority. */
49
+ priority?: SupportPriority;
50
+ /** One sentiment. */
51
+ sentiment?: SupportSentiment;
52
+ /** One channel — mail, or what signed-in users filed from inside the app. */
53
+ channel?: SupportChannel;
54
+ /** Which inbox address, for a Worker serving more than one. */
55
+ inbox?: string;
56
+ /** Free text over subjects and bodies. */
57
+ q?: string;
58
+ /** Where to resume from. */
59
+ cursor?: string;
60
+ /** How many to return. */
61
+ limit?: number;
62
+ }
63
+ /** A thread as the inbox lists it — the row, plus this viewer's own private state. */
64
+ export interface ListedThread extends SupportThread {
65
+ /** Whether this viewer has read it. False when they have no flag row, which is the common case. */
66
+ read: boolean;
67
+ /** When this viewer's snooze expires, or null. */
68
+ snoozedUntil: Date | null;
69
+ }
70
+ /** One page of the inbox. */
71
+ export interface ThreadPage {
72
+ /** The threads, newest first. */
73
+ threads: ListedThread[];
74
+ /** The cursor for the next page, or null when this was the last one. */
75
+ nextCursor: string | null;
76
+ }
77
+ /**
78
+ * List threads, as one viewer sees them.
79
+ *
80
+ * The viewer is a parameter rather than an afterthought because the per-viewer flags are **read
81
+ * here**: a snooze that was stored and never consulted is a button that does nothing, and `read` that
82
+ * is never projected is a column a dashboard cannot render. Both are left-joined, so a thread with no
83
+ * flag row for this viewer — the overwhelming majority — still appears, unread and unsnoozed.
84
+ */
85
+ export declare function listThreads(db: SupportDatabase, query: ListThreadsQuery, options: {
86
+ fts: boolean;
87
+ viewer: string;
88
+ now: Date;
89
+ log?: Logger;
90
+ }): Promise<ThreadPage>;
91
+ /** A thread with everything a reading pane shows. */
92
+ export interface ThreadDetail {
93
+ /** The thread row. */
94
+ thread: SupportThread;
95
+ /** Its messages, oldest first — a conversation reads downward. */
96
+ messages: SupportMessage[];
97
+ /** Its attachments, across every message. */
98
+ attachments: SupportAttachment[];
99
+ }
100
+ /** Read one thread in full. Throws `support/not_found` when it does not exist. */
101
+ export declare function readThread(db: SupportDatabase, threadId: string): Promise<ThreadDetail>;
102
+ /**
103
+ * List one account's own in-app conversations.
104
+ *
105
+ * **Two conditions on the `where`, and the second is not redundant.** `userId` alone would also return
106
+ * every *email* thread this capability linked to the account — and that link was matched against an
107
+ * address in a header nobody proved, so it names an account the sender merely claimed to be. Serving
108
+ * those to whoever currently holds the address would turn the mail path's known weakness into a read
109
+ * primitive, which is precisely the trade this channel exists to avoid making.
110
+ *
111
+ * Archived threads are included, unlike the operator inbox. An inbox hides done threads because it is a
112
+ * work queue; a person looking at their own requests is looking for the one that was answered.
113
+ */
114
+ export declare function listOwnThreads(db: SupportDatabase, userId: string, query: {
115
+ cursor?: string;
116
+ limit?: number;
117
+ }): Promise<{
118
+ threads: SupportThread[];
119
+ nextCursor: string | null;
120
+ }>;
121
+ /**
122
+ * Read one of an account's own in-app conversations.
123
+ *
124
+ * **A 404 for somebody else's thread, never a 403.** The two are the same answer on purpose: a 403
125
+ * confirms the id names a real conversation, and on an inbox of other people's correspondence that
126
+ * confirmation is itself the disclosure. The scoping is in the `where`, not in a check after the read,
127
+ * so there is no version of this that fetches the row first and forgets to compare.
128
+ */
129
+ export declare function readOwnThread(db: SupportDatabase, threadId: string, userId: string): Promise<ThreadDetail>;
130
+ /**
131
+ * Mark a thread done, or reopen it.
132
+ *
133
+ * The one shared piece of state in the model, and the write is unconditional rather than a
134
+ * compare-and-set: archiving something already archived is not a conflict, it is the same outcome,
135
+ * and a support console that threw on a double-click would be worse than one that did nothing.
136
+ */
137
+ export declare function setArchived(db: SupportDatabase, threadId: string, archived: boolean, viewer: string, now: Date): Promise<SupportThread>;
138
+ /** Set one viewer's private flags on a thread. Upserts on `(threadId, viewer)`. */
139
+ export declare function setFlags(db: SupportDatabase, options: {
140
+ threadId: string;
141
+ viewer: string;
142
+ read?: boolean;
143
+ snoozedUntil?: Date | null;
144
+ newId: () => string;
145
+ now: Date;
146
+ }): Promise<void>;
147
+ //# sourceMappingURL=threads.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"threads.d.ts","sourceRoot":"","sources":["../../src/store/threads.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kCAAkC,CAAC;AAE/D,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AACvD,OAAO,KAAK,EAAE,cAAc,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACvF,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjD,OAAO,EAML,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAK/C;;;;;;;;;;;;;;GAcG;AAEH,gDAAgD;AAChD,MAAM,WAAW,YAAY;IAC3B,gFAAgF;IAChF,aAAa,EAAE,MAAM,CAAC;IACtB,qEAAqE;IACrE,EAAE,EAAE,MAAM,CAAC;CACZ;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,MAAM,CAEzD;AAED,2GAA2G;AAC3G,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,YAAY,GAAG,SAAS,CAYhF;AAED,6CAA6C;AAC7C,MAAM,WAAW,gBAAgB;IAC/B,6DAA6D;IAC7D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,kFAAkF;IAClF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,oGAAoG;IACpG,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,oBAAoB;IACpB,QAAQ,CAAC,EAAE,eAAe,CAAC;IAC3B,qBAAqB;IACrB,SAAS,CAAC,EAAE,gBAAgB,CAAC;IAC7B,6EAA6E;IAC7E,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB,+DAA+D;IAC/D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,0CAA0C;IAC1C,CAAC,CAAC,EAAE,MAAM,CAAC;IACX,4BAA4B;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,0BAA0B;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,sFAAsF;AACtF,MAAM,WAAW,YAAa,SAAQ,aAAa;IACjD,mGAAmG;IACnG,IAAI,EAAE,OAAO,CAAC;IACd,kDAAkD;IAClD,YAAY,EAAE,IAAI,GAAG,IAAI,CAAC;CAC3B;AAED,6BAA6B;AAC7B,MAAM,WAAW,UAAU;IACzB,iCAAiC;IACjC,OAAO,EAAE,YAAY,EAAE,CAAC;IACxB,wEAAwE;IACxE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED;;;;;;;GAOG;AACH,wBAAsB,WAAW,CAC/B,EAAE,EAAE,eAAe,EACnB,KAAK,EAAE,gBAAgB,EACvB,OAAO,EAAE;IAAE,GAAG,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,IAAI,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GACjE,OAAO,CAAC,UAAU,CAAC,CAsBrB;AAqGD,qDAAqD;AACrD,MAAM,WAAW,YAAY;IAC3B,sBAAsB;IACtB,MAAM,EAAE,aAAa,CAAC;IACtB,kEAAkE;IAClE,QAAQ,EAAE,cAAc,EAAE,CAAC;IAC3B,6CAA6C;IAC7C,WAAW,EAAE,iBAAiB,EAAE,CAAC;CAClC;AAED,kFAAkF;AAClF,wBAAsB,UAAU,CAAC,EAAE,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAoB7F;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,cAAc,CAClC,EAAE,EAAE,eAAe,EACnB,MAAM,EAAE,MAAM,EACd,KAAK,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GACzC,OAAO,CAAC;IAAE,OAAO,EAAE,aAAa,EAAE,CAAC;IAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC,CA+BlE;AAED;;;;;;;GAOG;AACH,wBAAsB,aAAa,CAAC,EAAE,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CA0BhH;AAED;;;;;;GAMG;AACH,wBAAsB,WAAW,CAC/B,EAAE,EAAE,eAAe,EACnB,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,OAAO,EACjB,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,IAAI,GACR,OAAO,CAAC,aAAa,CAAC,CAsBxB;AAED,mFAAmF;AACnF,wBAAsB,QAAQ,CAC5B,EAAE,EAAE,eAAe,EACnB,OAAO,EAAE;IACP,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,YAAY,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IAC3B,KAAK,EAAE,MAAM,MAAM,CAAC;IACpB,GAAG,EAAE,IAAI,CAAC;CACX,GACA,OAAO,CAAC,IAAI,CAAC,CAsCf"}
@@ -0,0 +1,197 @@
1
+ import { SupportNotFoundError } from "../error/errors.js";
2
+ import { SupportAttachment } from "../data/attachment.js";
3
+ import { SupportMessage } from "../data/message.js";
4
+ import { SupportThread } from "../data/thread.js";
5
+ import { SUPPORT_ATTACHMENTS_TABLE, SUPPORT_FLAGS_TABLE, SUPPORT_MESSAGES_TABLE, SUPPORT_THREADS_TABLE } from "../data/tables.js";
6
+ import { isSearchable, searchPredicate } from "./search.js";
7
+ import "./paging.js";
8
+ import { sql } from "kysely";
9
+ //#region src/store/threads.ts
10
+ /**
11
+ * Encode a cursor for the wire.
12
+ *
13
+ * Base64url over JSON, and **opaque by intent rather than by obscurity**: the point is that a client
14
+ * cannot construct one by hand and therefore cannot come to depend on its shape, so the ordering can
15
+ * change later without breaking every caller. It is not a secret — it holds a timestamp and an id
16
+ * the caller was just given.
17
+ */
18
+ function encodeCursor(cursor) {
19
+ return btoa(JSON.stringify(cursor)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
20
+ }
21
+ /** Decode a cursor. Returns undefined for anything malformed — a bad cursor is a first page, not a 500. */
22
+ function decodeCursor(value) {
23
+ if (!value) return void 0;
24
+ try {
25
+ const json = atob(value.replace(/-/g, "+").replace(/_/g, "/"));
26
+ const parsed = JSON.parse(json);
27
+ if (typeof parsed !== "object" || parsed === null) return void 0;
28
+ const { lastMessageAt, id } = parsed;
29
+ if (typeof lastMessageAt !== "number" || typeof id !== "string") return void 0;
30
+ return {
31
+ lastMessageAt,
32
+ id
33
+ };
34
+ } catch {
35
+ return;
36
+ }
37
+ }
38
+ /**
39
+ * List threads, as one viewer sees them.
40
+ *
41
+ * The viewer is a parameter rather than an afterthought because the per-viewer flags are **read
42
+ * here**: a snooze that was stored and never consulted is a button that does nothing, and `read` that
43
+ * is never projected is a column a dashboard cannot render. Both are left-joined, so a thread with no
44
+ * flag row for this viewer — the overwhelming majority — still appears, unread and unsnoozed.
45
+ */
46
+ async function listThreads(db, query, options) {
47
+ try {
48
+ return await runListThreads(db, query, options);
49
+ } catch (error) {
50
+ if (options.fts && isMissingSearchTable(error)) {
51
+ 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`." });
52
+ return runListThreads(db, query, {
53
+ ...options,
54
+ fts: false
55
+ });
56
+ }
57
+ throw error;
58
+ }
59
+ }
60
+ /** Whether an error is SQLite saying the full-text index does not exist. */
61
+ function isMissingSearchTable(error) {
62
+ const message = error instanceof Error ? error.message : String(error);
63
+ return message.includes("no such table") && message.includes("pithy_support_search");
64
+ }
65
+ /** The listing itself, for one chosen search backend. */
66
+ async function runListThreads(db, query, options) {
67
+ const limit = Math.min(Math.max(query.limit ?? 25, 1), 100);
68
+ const cursor = decodeCursor(query.cursor);
69
+ let builder = db.selectFrom(SUPPORT_THREADS_TABLE).leftJoin(SUPPORT_FLAGS_TABLE, (join) => join.onRef(`${SUPPORT_FLAGS_TABLE}.threadId`, "=", `${SUPPORT_THREADS_TABLE}.id`).on(`${SUPPORT_FLAGS_TABLE}.viewer`, "=", options.viewer)).selectAll(SUPPORT_THREADS_TABLE).select([`${SUPPORT_FLAGS_TABLE}.read as viewerRead`, `${SUPPORT_FLAGS_TABLE}.snoozedUntil as viewerSnoozedUntil`]).where((eb) => eb.or([eb(`${SUPPORT_FLAGS_TABLE}.snoozedUntil`, "is", null), eb(`${SUPPORT_FLAGS_TABLE}.snoozedUntil`, "<=", options.now.getTime())])).where(`${SUPPORT_THREADS_TABLE}.archived`, "=", query.archived === true ? 1 : 0);
70
+ if (query.category !== void 0) builder = builder.where(`${SUPPORT_THREADS_TABLE}.category`, "=", query.category);
71
+ if (query.declaredCategory !== void 0) builder = builder.where(`${SUPPORT_THREADS_TABLE}.declaredCategory`, "=", query.declaredCategory);
72
+ if (query.priority !== void 0) builder = builder.where(`${SUPPORT_THREADS_TABLE}.priority`, "=", query.priority);
73
+ if (query.sentiment !== void 0) builder = builder.where(`${SUPPORT_THREADS_TABLE}.sentiment`, "=", query.sentiment);
74
+ if (query.channel !== void 0) builder = builder.where(`${SUPPORT_THREADS_TABLE}.channel`, "=", query.channel);
75
+ if (query.inbox !== void 0) builder = builder.where(`${SUPPORT_THREADS_TABLE}.inboxAddress`, "=", query.inbox);
76
+ if (query.q !== void 0) {
77
+ if (isSearchable(query.q, options.fts)) builder = builder.where(searchPredicate(query.q, options));
78
+ else builder = builder.where(sql`1 = 0`);
79
+ }
80
+ if (cursor) builder = builder.where((eb) => eb.or([eb(`${SUPPORT_THREADS_TABLE}.lastMessageAt`, "<", cursor.lastMessageAt), eb.and([eb(`${SUPPORT_THREADS_TABLE}.lastMessageAt`, "=", cursor.lastMessageAt), eb(`${SUPPORT_THREADS_TABLE}.id`, "<", cursor.id)])]));
81
+ const rows = await builder.orderBy(`${SUPPORT_THREADS_TABLE}.lastMessageAt`, "desc").orderBy(`${SUPPORT_THREADS_TABLE}.id`, "desc").limit(limit + 1).execute();
82
+ const page = rows.slice(0, limit).map((row) => ({
83
+ ...SupportThread.parse(row),
84
+ read: row.viewerRead === 1,
85
+ snoozedUntil: row.viewerSnoozedUntil == null ? null : new Date(Number(row.viewerSnoozedUntil))
86
+ }));
87
+ const last = page[page.length - 1];
88
+ return {
89
+ threads: page,
90
+ nextCursor: rows.length > limit && last ? encodeCursor({
91
+ lastMessageAt: last.lastMessageAt.getTime(),
92
+ id: last.id
93
+ }) : null
94
+ };
95
+ }
96
+ /** Read one thread in full. Throws `support/not_found` when it does not exist. */
97
+ async function readThread(db, threadId) {
98
+ const row = await db.selectFrom(SUPPORT_THREADS_TABLE).selectAll().where("id", "=", threadId).executeTakeFirst();
99
+ if (!row) throw new SupportNotFoundError({ detail: `no support thread ${threadId}` });
100
+ const [messages, attachments] = await Promise.all([db.selectFrom(SUPPORT_MESSAGES_TABLE).selectAll().where("threadId", "=", threadId).orderBy("receivedAt", "asc").orderBy("id", "asc").execute(), db.selectFrom(SUPPORT_ATTACHMENTS_TABLE).selectAll().where("threadId", "=", threadId).execute()]);
101
+ return {
102
+ thread: SupportThread.parse(row),
103
+ messages: messages.map((message) => SupportMessage.parse(message)),
104
+ attachments: attachments.map((attachment) => SupportAttachment.parse(attachment))
105
+ };
106
+ }
107
+ /**
108
+ * List one account's own in-app conversations.
109
+ *
110
+ * **Two conditions on the `where`, and the second is not redundant.** `userId` alone would also return
111
+ * every *email* thread this capability linked to the account — and that link was matched against an
112
+ * address in a header nobody proved, so it names an account the sender merely claimed to be. Serving
113
+ * those to whoever currently holds the address would turn the mail path's known weakness into a read
114
+ * primitive, which is precisely the trade this channel exists to avoid making.
115
+ *
116
+ * Archived threads are included, unlike the operator inbox. An inbox hides done threads because it is a
117
+ * work queue; a person looking at their own requests is looking for the one that was answered.
118
+ */
119
+ async function listOwnThreads(db, userId, query) {
120
+ const limit = Math.min(Math.max(query.limit ?? 25, 1), 100);
121
+ const cursor = decodeCursor(query.cursor);
122
+ let builder = db.selectFrom(SUPPORT_THREADS_TABLE).selectAll().where("userId", "=", userId).where("channel", "=", "app");
123
+ if (cursor) builder = builder.where((eb) => eb.or([eb("lastMessageAt", "<", cursor.lastMessageAt), eb.and([eb("lastMessageAt", "=", cursor.lastMessageAt), eb("id", "<", cursor.id)])]));
124
+ const rows = await builder.orderBy("lastMessageAt", "desc").orderBy("id", "desc").limit(limit + 1).execute();
125
+ const page = rows.slice(0, limit).map((row) => SupportThread.parse(row));
126
+ const last = page[page.length - 1];
127
+ return {
128
+ threads: page,
129
+ nextCursor: rows.length > limit && last ? encodeCursor({
130
+ lastMessageAt: last.lastMessageAt.getTime(),
131
+ id: last.id
132
+ }) : null
133
+ };
134
+ }
135
+ /**
136
+ * Read one of an account's own in-app conversations.
137
+ *
138
+ * **A 404 for somebody else's thread, never a 403.** The two are the same answer on purpose: a 403
139
+ * confirms the id names a real conversation, and on an inbox of other people's correspondence that
140
+ * confirmation is itself the disclosure. The scoping is in the `where`, not in a check after the read,
141
+ * so there is no version of this that fetches the row first and forgets to compare.
142
+ */
143
+ async function readOwnThread(db, threadId, userId) {
144
+ const row = await db.selectFrom(SUPPORT_THREADS_TABLE).selectAll().where("id", "=", threadId).where("userId", "=", userId).where("channel", "=", "app").executeTakeFirst();
145
+ if (!row) throw new SupportNotFoundError({ detail: `no app thread ${threadId} owned by ${userId}` });
146
+ const [messages, attachments] = await Promise.all([db.selectFrom(SUPPORT_MESSAGES_TABLE).selectAll().where("threadId", "=", threadId).orderBy("receivedAt", "asc").orderBy("id", "asc").execute(), db.selectFrom(SUPPORT_ATTACHMENTS_TABLE).selectAll().where("threadId", "=", threadId).execute()]);
147
+ return {
148
+ thread: SupportThread.parse(row),
149
+ messages: messages.map((message) => SupportMessage.parse(message)),
150
+ attachments: attachments.map((attachment) => SupportAttachment.parse(attachment))
151
+ };
152
+ }
153
+ /**
154
+ * Mark a thread done, or reopen it.
155
+ *
156
+ * The one shared piece of state in the model, and the write is unconditional rather than a
157
+ * compare-and-set: archiving something already archived is not a conflict, it is the same outcome,
158
+ * and a support console that threw on a double-click would be worse than one that did nothing.
159
+ */
160
+ async function setArchived(db, threadId, archived, viewer, now) {
161
+ const existing = await db.selectFrom(SUPPORT_THREADS_TABLE).selectAll().where("id", "=", threadId).executeTakeFirst();
162
+ if (!existing) throw new SupportNotFoundError({ detail: `no support thread ${threadId}` });
163
+ await db.updateTable(SUPPORT_THREADS_TABLE).set({
164
+ archived: archived ? 1 : 0,
165
+ archivedAt: archived ? now.getTime() : null,
166
+ archivedBy: archived ? viewer : null,
167
+ updatedAt: now.getTime()
168
+ }).where("id", "=", threadId).execute();
169
+ return SupportThread.parse({
170
+ ...existing,
171
+ archived: archived ? 1 : 0,
172
+ archivedAt: archived ? now.getTime() : null,
173
+ archivedBy: archived ? viewer : null,
174
+ updatedAt: now.getTime()
175
+ });
176
+ }
177
+ /** Set one viewer's private flags on a thread. Upserts on `(threadId, viewer)`. */
178
+ async function setFlags(db, options) {
179
+ if (!await db.selectFrom("pithySupportThreads").select(["id"]).where("id", "=", options.threadId).executeTakeFirst()) throw new SupportNotFoundError({ detail: `no support thread ${options.threadId}` });
180
+ const read = options.read === void 0 ? void 0 : options.read ? 1 : 0;
181
+ const snoozed = options.snoozedUntil === void 0 ? void 0 : options.snoozedUntil?.getTime() ?? null;
182
+ await db.insertInto(SUPPORT_FLAGS_TABLE).values({
183
+ id: options.newId(),
184
+ threadId: options.threadId,
185
+ viewer: options.viewer,
186
+ read: read ?? 0,
187
+ snoozedUntil: snoozed ?? null,
188
+ createdAt: options.now.getTime(),
189
+ updatedAt: options.now.getTime()
190
+ }).onConflict((oc) => oc.columns(["threadId", "viewer"]).doUpdateSet((eb) => ({
191
+ read: read === void 0 ? eb.ref(`${SUPPORT_FLAGS_TABLE}.read`) : read,
192
+ snoozedUntil: snoozed === void 0 ? eb.ref(`${SUPPORT_FLAGS_TABLE}.snoozedUntil`) : snoozed,
193
+ updatedAt: options.now.getTime()
194
+ }))).execute();
195
+ }
196
+ //#endregion
197
+ export { decodeCursor, encodeCursor, listOwnThreads, listThreads, readOwnThread, readThread, setArchived, setFlags };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The largest base64 string worth decoding, derived from a decoded-byte bound.
3
+ *
4
+ * Checked **before** decoding, not after. `atob` materialises the whole result, so a client that sends
5
+ * fifty megabytes of base64 has already been allocated fifty megabytes of Worker memory by the time a
6
+ * post-hoc size check could refuse it — and the request that does that is the one an attacker sends.
7
+ * A little slack for padding and any whitespace a client's encoder inserted.
8
+ */
9
+ export declare function maxEncodedLength(maxBytes: number): number;
10
+ /**
11
+ * Decode one base64 attachment payload.
12
+ *
13
+ * Accepts the URL-safe alphabet too: a client that reached for `base64url` because its platform's
14
+ * default encoder produces it has not made a mistake worth a 400, and the two differ by two
15
+ * characters. Anything that is not base64 at all is a `validation/invalid_input`, which is what the
16
+ * route's own validator would have raised had the string been checkable there.
17
+ */
18
+ export declare function decodeBase64(value: string, options: {
19
+ maxBytes: number;
20
+ }): Uint8Array;
21
+ //# sourceMappingURL=encoding.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"encoding.d.ts","sourceRoot":"","sources":["../../src/submission/encoding.ts"],"names":[],"mappings":"AAkBA;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEzD;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,UAAU,CA2BrF"}