@pithy-sh/support 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/README.md +8 -0
  2. package/dist/audit/actions.d.ts +9 -0
  3. package/dist/audit/actions.d.ts.map +1 -1
  4. package/dist/audit/actions.js +9 -0
  5. package/dist/capability.d.ts +2 -1
  6. package/dist/capability.d.ts.map +1 -1
  7. package/dist/capability.js +30 -6
  8. package/dist/config/config.d.ts +63 -2
  9. package/dist/config/config.d.ts.map +1 -1
  10. package/dist/config/config.js +63 -5
  11. package/dist/data/assignment.d.ts +43 -0
  12. package/dist/data/assignment.d.ts.map +1 -0
  13. package/dist/data/assignment.js +38 -0
  14. package/dist/data/embedding.d.ts +21 -0
  15. package/dist/data/embedding.d.ts.map +1 -0
  16. package/dist/data/embedding.js +23 -0
  17. package/dist/data/tables.d.ts +28 -0
  18. package/dist/data/tables.d.ts.map +1 -1
  19. package/dist/data/tables.js +10 -2
  20. package/dist/data/thread.d.ts +9 -4
  21. package/dist/data/thread.d.ts.map +1 -1
  22. package/dist/data/thread.js +9 -4
  23. package/dist/http/guards.d.ts +32 -14
  24. package/dist/http/guards.d.ts.map +1 -1
  25. package/dist/http/guards.js +44 -16
  26. package/dist/http/handlers.d.ts +35 -2
  27. package/dist/http/handlers.d.ts.map +1 -1
  28. package/dist/http/handlers.js +81 -8
  29. package/dist/http/resolve.d.ts +8 -0
  30. package/dist/http/resolve.d.ts.map +1 -1
  31. package/dist/http/resolve.js +20 -1
  32. package/dist/http/responses.d.ts +355 -0
  33. package/dist/http/responses.d.ts.map +1 -1
  34. package/dist/http/responses.js +64 -2
  35. package/dist/http/routes.d.ts +25 -11
  36. package/dist/http/routes.d.ts.map +1 -1
  37. package/dist/http/routes.js +34 -5
  38. package/dist/http/schemas.d.ts +67 -3
  39. package/dist/http/schemas.d.ts.map +1 -1
  40. package/dist/http/schemas.js +44 -5
  41. package/dist/http/scopes.d.ts +10 -5
  42. package/dist/http/scopes.d.ts.map +1 -1
  43. package/dist/http/scopes.js +42 -6
  44. package/dist/http/views.d.ts +9 -1
  45. package/dist/http/views.d.ts.map +1 -1
  46. package/dist/http/views.js +40 -1
  47. package/dist/inbound/ingest.d.ts +1 -1
  48. package/dist/inbound/ingest.d.ts.map +1 -1
  49. package/dist/inbound/ingest.js +3 -1
  50. package/dist/index.d.ts +5 -3
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +6 -4
  53. package/dist/migrations/0002_assignment.d.ts +20 -0
  54. package/dist/migrations/0002_assignment.d.ts.map +1 -0
  55. package/dist/migrations/0002_assignment.js +45 -0
  56. package/dist/migrations/0003_embeddings.d.ts +15 -0
  57. package/dist/migrations/0003_embeddings.d.ts.map +1 -0
  58. package/dist/migrations/0003_embeddings.js +23 -0
  59. package/dist/migrations/set.d.ts +13 -0
  60. package/dist/migrations/set.d.ts.map +1 -0
  61. package/dist/migrations/set.js +21 -0
  62. package/dist/presence/room.d.ts +35 -0
  63. package/dist/presence/room.d.ts.map +1 -0
  64. package/dist/presence/room.js +66 -0
  65. package/dist/provision/provisionSupport.d.ts +32 -0
  66. package/dist/provision/provisionSupport.d.ts.map +1 -1
  67. package/dist/provision/provisionSupport.js +10 -2
  68. package/dist/provision/resolveSupportConfig.d.ts.map +1 -1
  69. package/dist/provision/resolveSupportConfig.js +13 -2
  70. package/dist/purge/contribution.d.ts +49 -0
  71. package/dist/purge/contribution.d.ts.map +1 -0
  72. package/dist/purge/contribution.js +164 -0
  73. package/dist/secret/registry.d.ts +26 -2
  74. package/dist/secret/registry.d.ts.map +1 -1
  75. package/dist/secret/registry.js +15 -3
  76. package/dist/semantic/documents.d.ts +21 -0
  77. package/dist/semantic/documents.d.ts.map +1 -0
  78. package/dist/semantic/documents.js +128 -0
  79. package/dist/semantic/embed.d.ts +59 -0
  80. package/dist/semantic/embed.d.ts.map +1 -0
  81. package/dist/semantic/embed.js +59 -0
  82. package/dist/semantic/messageIndex.d.ts +46 -0
  83. package/dist/semantic/messageIndex.d.ts.map +1 -0
  84. package/dist/semantic/messageIndex.js +67 -0
  85. package/dist/semantic/query.d.ts +37 -0
  86. package/dist/semantic/query.d.ts.map +1 -0
  87. package/dist/semantic/query.js +46 -0
  88. package/dist/staff/staff.d.ts +22 -0
  89. package/dist/staff/staff.d.ts.map +1 -0
  90. package/dist/staff/staff.js +64 -0
  91. package/dist/store/assignment.d.ts +63 -0
  92. package/dist/store/assignment.d.ts.map +1 -0
  93. package/dist/store/assignment.js +86 -0
  94. package/dist/store/hybrid.d.ts +134 -0
  95. package/dist/store/hybrid.d.ts.map +1 -0
  96. package/dist/store/hybrid.js +185 -0
  97. package/dist/store/paging.d.ts +10 -0
  98. package/dist/store/paging.d.ts.map +1 -1
  99. package/dist/store/paging.js +11 -1
  100. package/dist/store/provisionIndex.d.ts +21 -0
  101. package/dist/store/provisionIndex.d.ts.map +1 -0
  102. package/dist/store/provisionIndex.js +36 -0
  103. package/dist/store/search.d.ts +18 -9
  104. package/dist/store/search.d.ts.map +1 -1
  105. package/dist/store/search.js +36 -34
  106. package/dist/store/searchIndex.d.ts +4 -4
  107. package/dist/store/searchIndex.js +4 -4
  108. package/dist/store/threads.d.ts +174 -8
  109. package/dist/store/threads.d.ts.map +1 -1
  110. package/dist/store/threads.js +33 -40
  111. package/dist/submission/submit.d.ts +1 -1
  112. package/dist/submission/submit.d.ts.map +1 -1
  113. package/dist/submission/submit.js +2 -1
  114. package/dist/test-utils/seed.d.ts +33 -0
  115. package/dist/test-utils/seed.d.ts.map +1 -0
  116. package/dist/test-utils/seed.js +102 -0
  117. package/dist/test-utils/semantic.d.ts +21 -0
  118. package/dist/test-utils/semantic.d.ts.map +1 -0
  119. package/dist/test-utils/semantic.js +107 -0
  120. package/dist/version.generated.d.ts +1 -1
  121. package/dist/version.generated.js +1 -1
  122. package/dist/workflows/specs.d.ts +23 -0
  123. package/dist/workflows/specs.d.ts.map +1 -1
  124. package/dist/workflows/specs.js +27 -7
  125. package/dist/workflows/worker.d.ts +15 -2
  126. package/dist/workflows/worker.d.ts.map +1 -1
  127. package/dist/workflows/worker.js +48 -4
  128. package/package.json +9 -7
  129. package/pithy.manifest.json +21 -3
  130. package/src/audit/actions.ts +9 -0
  131. package/src/capability.ts +43 -4
  132. package/src/config/config.ts +104 -5
  133. package/src/data/assignment.ts +55 -0
  134. package/src/data/embedding.ts +27 -0
  135. package/src/data/tables.ts +8 -0
  136. package/src/data/thread.ts +26 -4
  137. package/src/http/guards.ts +52 -15
  138. package/src/http/handlers.ts +125 -4
  139. package/src/http/resolve.ts +19 -0
  140. package/src/http/responses.ts +154 -0
  141. package/src/http/routes.ts +98 -12
  142. package/src/http/schemas.ts +117 -28
  143. package/src/http/scopes.ts +43 -6
  144. package/src/http/views.ts +44 -0
  145. package/src/inbound/ingest.ts +5 -2
  146. package/src/index.ts +5 -0
  147. package/src/migrations/0002_assignment.ts +65 -0
  148. package/src/migrations/0003_embeddings.ts +29 -0
  149. package/src/migrations/set.ts +21 -0
  150. package/src/presence/room.ts +94 -0
  151. package/src/provision/provisionSupport.ts +33 -1
  152. package/src/provision/resolveSupportConfig.ts +24 -13
  153. package/src/purge/contribution.ts +294 -0
  154. package/src/secret/registry.ts +17 -2
  155. package/src/semantic/documents.ts +178 -0
  156. package/src/semantic/embed.ts +99 -0
  157. package/src/semantic/messageIndex.ts +74 -0
  158. package/src/semantic/query.ts +66 -0
  159. package/src/staff/staff.ts +128 -0
  160. package/src/store/assignment.ts +179 -0
  161. package/src/store/hybrid.ts +314 -0
  162. package/src/store/paging.ts +14 -0
  163. package/src/store/provisionIndex.ts +46 -0
  164. package/src/store/search.ts +58 -37
  165. package/src/store/searchIndex.ts +4 -4
  166. package/src/store/threads.ts +91 -91
  167. package/src/submission/submit.ts +3 -2
  168. package/src/test-utils/seed.ts +141 -0
  169. package/src/test-utils/semantic.ts +110 -0
  170. package/src/version.generated.ts +1 -1
  171. package/src/workflows/specs.ts +34 -0
  172. package/src/workflows/worker.ts +84 -8
  173. package/src/workflows/wrangler.jsonc +8 -3
@@ -1,10 +1,11 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
- import type { Logger } from "@pithy-sh/core/src/logger/logger";
3
+ import type { DatabaseSchema } from "@pithy-sh/core/src/data/db";
4
+ import type { SelectQueryBuilder } from "kysely";
4
5
  import { SupportAttachment } from "../data/attachment";
5
6
  import type { SupportChannel, SupportPriority, SupportSentiment } from "../data/enums";
6
7
  import { SupportMessage } from "../data/message";
7
- import { type SupportDatabase } from "../data/tables";
8
+ import { SUPPORT_FLAGS_TABLE, SUPPORT_THREADS_TABLE, type SupportDatabase, type SupportTables } from "../data/tables";
8
9
  import { SupportThread } from "../data/thread";
9
10
  /**
10
11
  * The inbox query — the one read this whole data model was shaped around.
@@ -39,8 +40,12 @@ export interface ThreadCursor {
39
40
  export declare function encodeCursor(cursor: ThreadCursor): string;
40
41
  /** Decode a cursor. Returns undefined for anything malformed — a bad cursor is a first page, not a 500. */
41
42
  export declare function decodeCursor(value: string | undefined): ThreadCursor | undefined;
42
- /** What a caller may filter the inbox by. */
43
- export interface ListThreadsQuery {
43
+ /**
44
+ * What a caller may narrow threads by — the inbox listing and the search route alike. Search applies
45
+ * these in D1, after retrieval, never as Vectorize metadata: metadata would go stale the moment a thread is
46
+ * archived, reassigned or reclassified.
47
+ */
48
+ export interface ThreadFilters {
44
49
  /** Open threads, done threads, or both. Defaults to open. */
45
50
  archived?: boolean;
46
51
  /** One category key from the effective taxonomy — what the classifier decided. */
@@ -55,8 +60,16 @@ export interface ListThreadsQuery {
55
60
  channel?: SupportChannel;
56
61
  /** Which inbox address, for a Worker serving more than one. */
57
62
  inbox?: string;
58
- /** Free text over subjects and bodies. */
59
- q?: string;
63
+ /** Whose threads: one assignee's opaque ref, or {@link UNASSIGNED} for the threads nobody has. */
64
+ assignee?: string;
65
+ }
66
+ /**
67
+ * What a caller may filter the inbox by, and where to resume.
68
+ *
69
+ * **No free text.** Listing lists; `GET {basePath}/threads/search` searches, ranked, with a limit and no
70
+ * cursor. A `q` here could say whether a thread matched, never how well.
71
+ */
72
+ export interface ListThreadsQuery extends ThreadFilters {
60
73
  /** Where to resume from. */
61
74
  cursor?: string;
62
75
  /** How many to return. */
@@ -76,6 +89,160 @@ export interface ThreadPage {
76
89
  /** The cursor for the next page, or null when this was the last one. */
77
90
  nextCursor: string | null;
78
91
  }
92
+ type SupportSchema = DatabaseSchema<SupportTables>;
93
+ type ThreadsAndFlags = typeof SUPPORT_THREADS_TABLE | typeof SUPPORT_FLAGS_TABLE;
94
+ /**
95
+ * Threads joined to one viewer's own flags.
96
+ *
97
+ * Joined on the viewer as well as the thread, so one operator's snooze never hides a thread from anybody
98
+ * else — the whole reason these flags are per viewer rather than on the thread.
99
+ */
100
+ export declare function viewerThreads(db: SupportDatabase, viewer: string): SelectQueryBuilder<{
101
+ pithySupportAssignments: {
102
+ id: string;
103
+ threadId: string;
104
+ assigneeRef?: string | null | undefined;
105
+ assigneeName?: string | null | undefined;
106
+ previousRef?: string | null | undefined;
107
+ previousName?: string | null | undefined;
108
+ actorType: "control-plane" | "user";
109
+ assignedBy: string;
110
+ note?: string | null | undefined;
111
+ createdAt: string | number | Date;
112
+ };
113
+ pithySupportAttachments: {
114
+ id: string;
115
+ messageId: string;
116
+ threadId: string;
117
+ filename: string;
118
+ contentType: string;
119
+ size: number;
120
+ sha256: string;
121
+ storageKey: string;
122
+ contentId?: string | null | undefined;
123
+ inline: string | 0 | 1 | boolean;
124
+ createdAt: string | number | Date;
125
+ };
126
+ pithySupportClassifications: {
127
+ id: string;
128
+ threadId: string;
129
+ messageId: string;
130
+ category: string;
131
+ priority: "low" | "normal" | "urgent";
132
+ sentiment: "angry" | "frustrated" | "neutral" | "positive";
133
+ confidence: number;
134
+ model: string;
135
+ createdAt: string | number | Date;
136
+ };
137
+ pithySupportEmbeddings: {
138
+ messageId: string;
139
+ model: string;
140
+ embeddedAt: string | number | Date;
141
+ };
142
+ pithySupportMessages: {
143
+ id: string;
144
+ threadId: string;
145
+ direction: "inbound" | "outbound";
146
+ channel: "app" | "email";
147
+ submittedByUserId?: string | null | undefined;
148
+ context?: string | {
149
+ screen?: string | undefined;
150
+ appVersion?: string | undefined;
151
+ platform?: string | undefined;
152
+ environment?: string | undefined;
153
+ locale?: string | undefined;
154
+ } | null | undefined;
155
+ mimeMessageId?: string | null | undefined;
156
+ mimeInReplyTo?: string | null | undefined;
157
+ mimeReferences?: string | string[] | null | undefined;
158
+ fromAddress?: string | null | undefined;
159
+ fromName?: string | null | undefined;
160
+ toAddress?: string | null | undefined;
161
+ subject: string;
162
+ textBody: string;
163
+ htmlBody?: string | null | undefined;
164
+ emailJobId?: string | null | undefined;
165
+ rawKey?: string | null | undefined;
166
+ rawBytes?: number | null | undefined;
167
+ receivedAt: string | number | Date;
168
+ createdAt: string | number | Date;
169
+ };
170
+ pithySupportThreadFlags: import("kysely").Nullable<{
171
+ id: string;
172
+ threadId: string;
173
+ viewer: string;
174
+ read: string | 0 | 1 | boolean;
175
+ snoozedUntil?: string | number | Date | null | undefined;
176
+ createdAt: string | number | Date;
177
+ updatedAt: string | number | Date;
178
+ }>;
179
+ pithySupportThreads: {
180
+ id: string;
181
+ channel: "app" | "email";
182
+ inboxAddress?: string | null | undefined;
183
+ subject: string;
184
+ fromAddress: string;
185
+ fromName?: string | null | undefined;
186
+ senderAuthenticated: string | 0 | 1 | boolean;
187
+ userId?: string | null | undefined;
188
+ accountLinkSource?: "email_address" | "session" | null | undefined;
189
+ declaredCategory?: string | null | undefined;
190
+ category: string;
191
+ priority: "low" | "normal" | "urgent";
192
+ sentiment: "angry" | "frustrated" | "neutral" | "positive";
193
+ confidence?: number | null | undefined;
194
+ model?: string | null | undefined;
195
+ classifiedAt?: string | number | Date | null | undefined;
196
+ archived: string | 0 | 1 | boolean;
197
+ archivedAt?: string | number | Date | null | undefined;
198
+ archivedBy?: string | null | undefined;
199
+ assigneeRef?: string | null | undefined;
200
+ assigneeName?: string | null | undefined;
201
+ assignedAt?: string | number | Date | null | undefined;
202
+ assignedBy?: string | null | undefined;
203
+ messageCount: number;
204
+ firstMessageAt: string | number | Date;
205
+ lastMessageAt: string | number | Date;
206
+ createdAt: string | number | Date;
207
+ updatedAt: string | number | Date;
208
+ };
209
+ }, "pithySupportThreadFlags" | "pithySupportThreads", {
210
+ accountLinkSource: "email_address" | "session" | null | undefined;
211
+ archived: string | 0 | 1 | boolean;
212
+ archivedAt: string | number | Date | null | undefined;
213
+ archivedBy: string | null | undefined;
214
+ assignedAt: string | number | Date | null | undefined;
215
+ assignedBy: string | null | undefined;
216
+ assigneeName: string | null | undefined;
217
+ assigneeRef: string | null | undefined;
218
+ category: string;
219
+ channel: "app" | "email";
220
+ classifiedAt: string | number | Date | null | undefined;
221
+ confidence: number | null | undefined;
222
+ createdAt: string | number | Date;
223
+ declaredCategory: string | null | undefined;
224
+ firstMessageAt: string | number | Date;
225
+ fromAddress: string;
226
+ fromName: string | null | undefined;
227
+ id: string;
228
+ inboxAddress: string | null | undefined;
229
+ lastMessageAt: string | number | Date;
230
+ messageCount: number;
231
+ model: string | null | undefined;
232
+ priority: "low" | "normal" | "urgent";
233
+ senderAuthenticated: string | 0 | 1 | boolean;
234
+ sentiment: "angry" | "frustrated" | "neutral" | "positive";
235
+ subject: string;
236
+ updatedAt: string | number | Date;
237
+ userId: string | null | undefined;
238
+ } & {
239
+ viewerRead: string | 0 | 1 | boolean | null;
240
+ viewerSnoozedUntil: string | number | Date | null | undefined;
241
+ }>;
242
+ /** A row out of {@link viewerThreads}, as the listing and the search both return it. */
243
+ export declare function listedThreadOf(row: Record<string, unknown>): ListedThread;
244
+ /** Narrow a {@link viewerThreads} query by every filter the caller set — one statement of them, for both routes. */
245
+ export declare function applyThreadFilters<O>(builder: SelectQueryBuilder<SupportSchema, ThreadsAndFlags, O>, filters: ThreadFilters): SelectQueryBuilder<SupportSchema, ThreadsAndFlags, O>;
79
246
  /**
80
247
  * List threads, as one viewer sees them.
81
248
  *
@@ -85,10 +252,8 @@ export interface ThreadPage {
85
252
  * flag row for this viewer — the overwhelming majority — still appears, unread and unsnoozed.
86
253
  */
87
254
  export declare function listThreads(db: SupportDatabase, query: ListThreadsQuery, options: {
88
- fts: boolean;
89
255
  viewer: string;
90
256
  now: Date;
91
- log?: Logger;
92
257
  }): Promise<ThreadPage>;
93
258
  /** A thread with everything a reading pane shows. */
94
259
  export interface ThreadDetail {
@@ -146,4 +311,5 @@ export declare function setFlags(db: SupportDatabase, options: {
146
311
  newId: () => string;
147
312
  now: Date;
148
313
  }): Promise<void>;
314
+ export {};
149
315
  //# sourceMappingURL=threads.d.ts.map
@@ -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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"}
@@ -1,14 +1,12 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
  import { SupportNotFoundError } from "../error/errors.js";
4
+ import "../data/assignment.js";
4
5
  import { SupportAttachment } from "../data/attachment.js";
5
6
  import { SupportMessage } from "../data/message.js";
6
7
  import { SupportThread } from "../data/thread.js";
7
8
  import { SUPPORT_ATTACHMENTS_TABLE, SUPPORT_FLAGS_TABLE, SUPPORT_MESSAGES_TABLE, SUPPORT_THREADS_TABLE } from "../data/tables.js";
8
9
  import "./paging.js";
9
- import { isSearchable, searchPredicate } from "./search.js";
10
- import { pithyCommandOf } from "@pithy-sh/core/src/cli/command";
11
- import { sql } from "kysely";
12
10
  //#region src/store/threads.ts
13
11
  /**
14
12
  * Encode a cursor for the wire.
@@ -39,6 +37,35 @@ function decodeCursor(value) {
39
37
  }
40
38
  }
41
39
  /**
40
+ * Threads joined to one viewer's own flags.
41
+ *
42
+ * Joined on the viewer as well as the thread, so one operator's snooze never hides a thread from anybody
43
+ * else — the whole reason these flags are per viewer rather than on the thread.
44
+ */
45
+ function viewerThreads(db, viewer) {
46
+ return 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`, "=", viewer)).selectAll(SUPPORT_THREADS_TABLE).select([`${SUPPORT_FLAGS_TABLE}.read as viewerRead`, `${SUPPORT_FLAGS_TABLE}.snoozedUntil as viewerSnoozedUntil`]);
47
+ }
48
+ /** A row out of {@link viewerThreads}, as the listing and the search both return it. */
49
+ function listedThreadOf(row) {
50
+ return {
51
+ ...SupportThread.parse(row),
52
+ read: row.viewerRead === 1,
53
+ snoozedUntil: row.viewerSnoozedUntil == null ? null : new Date(Number(row.viewerSnoozedUntil))
54
+ };
55
+ }
56
+ /** Narrow a {@link viewerThreads} query by every filter the caller set — one statement of them, for both routes. */
57
+ function applyThreadFilters(builder, filters) {
58
+ let filtered = builder.where(`${SUPPORT_THREADS_TABLE}.archived`, "=", filters.archived === true ? 1 : 0);
59
+ if (filters.category !== void 0) filtered = filtered.where(`${SUPPORT_THREADS_TABLE}.category`, "=", filters.category);
60
+ if (filters.declaredCategory !== void 0) filtered = filtered.where(`${SUPPORT_THREADS_TABLE}.declaredCategory`, "=", filters.declaredCategory);
61
+ if (filters.priority !== void 0) filtered = filtered.where(`${SUPPORT_THREADS_TABLE}.priority`, "=", filters.priority);
62
+ if (filters.sentiment !== void 0) filtered = filtered.where(`${SUPPORT_THREADS_TABLE}.sentiment`, "=", filters.sentiment);
63
+ if (filters.channel !== void 0) filtered = filtered.where(`${SUPPORT_THREADS_TABLE}.channel`, "=", filters.channel);
64
+ if (filters.inbox !== void 0) filtered = filtered.where(`${SUPPORT_THREADS_TABLE}.inboxAddress`, "=", filters.inbox);
65
+ if (filters.assignee !== void 0) filtered = filters.assignee === "none" ? filtered.where(`${SUPPORT_THREADS_TABLE}.assigneeRef`, "is", null) : filtered.where(`${SUPPORT_THREADS_TABLE}.assigneeRef`, "=", filters.assignee);
66
+ return filtered;
67
+ }
68
+ /**
42
69
  * List threads, as one viewer sees them.
43
70
  *
44
71
  * The viewer is a parameter rather than an afterthought because the per-viewer flags are **read
@@ -47,46 +74,12 @@ function decodeCursor(value) {
47
74
  * flag row for this viewer — the overwhelming majority — still appears, unread and unsnoozed.
48
75
  */
49
76
  async function listThreads(db, query, options) {
50
- try {
51
- return await runListThreads(db, query, options);
52
- } catch (error) {
53
- if (options.fts && isMissingSearchTable(error)) {
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\`.` });
55
- return runListThreads(db, query, {
56
- ...options,
57
- fts: false
58
- });
59
- }
60
- throw error;
61
- }
62
- }
63
- /** Whether an error is SQLite saying the full-text index does not exist. */
64
- function isMissingSearchTable(error) {
65
- const message = error instanceof Error ? error.message : String(error);
66
- return message.includes("no such table") && message.includes("pithy_support_search");
67
- }
68
- /** The listing itself, for one chosen search backend. */
69
- async function runListThreads(db, query, options) {
70
77
  const limit = Math.min(Math.max(query.limit ?? 25, 1), 100);
71
78
  const cursor = decodeCursor(query.cursor);
72
- 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);
73
- if (query.category !== void 0) builder = builder.where(`${SUPPORT_THREADS_TABLE}.category`, "=", query.category);
74
- if (query.declaredCategory !== void 0) builder = builder.where(`${SUPPORT_THREADS_TABLE}.declaredCategory`, "=", query.declaredCategory);
75
- if (query.priority !== void 0) builder = builder.where(`${SUPPORT_THREADS_TABLE}.priority`, "=", query.priority);
76
- if (query.sentiment !== void 0) builder = builder.where(`${SUPPORT_THREADS_TABLE}.sentiment`, "=", query.sentiment);
77
- if (query.channel !== void 0) builder = builder.where(`${SUPPORT_THREADS_TABLE}.channel`, "=", query.channel);
78
- if (query.inbox !== void 0) builder = builder.where(`${SUPPORT_THREADS_TABLE}.inboxAddress`, "=", query.inbox);
79
- if (query.q !== void 0) {
80
- if (isSearchable(query.q, options.fts)) builder = builder.where(searchPredicate(query.q, options));
81
- else builder = builder.where(sql`1 = 0`);
82
- }
79
+ let builder = applyThreadFilters(viewerThreads(db, options.viewer), query).where((eb) => eb.or([eb(`${SUPPORT_FLAGS_TABLE}.snoozedUntil`, "is", null), eb(`${SUPPORT_FLAGS_TABLE}.snoozedUntil`, "<=", options.now.getTime())]));
83
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)])]));
84
81
  const rows = await builder.orderBy(`${SUPPORT_THREADS_TABLE}.lastMessageAt`, "desc").orderBy(`${SUPPORT_THREADS_TABLE}.id`, "desc").limit(limit + 1).execute();
85
- const page = rows.slice(0, limit).map((row) => ({
86
- ...SupportThread.parse(row),
87
- read: row.viewerRead === 1,
88
- snoozedUntil: row.viewerSnoozedUntil == null ? null : new Date(Number(row.viewerSnoozedUntil))
89
- }));
82
+ const page = rows.slice(0, limit).map(listedThreadOf);
90
83
  const last = page[page.length - 1];
91
84
  return {
92
85
  threads: page,
@@ -197,4 +190,4 @@ async function setFlags(db, options) {
197
190
  }))).execute();
198
191
  }
199
192
  //#endregion
200
- export { decodeCursor, encodeCursor, listOwnThreads, listThreads, readOwnThread, readThread, setArchived, setFlags };
193
+ export { applyThreadFilters, decodeCursor, encodeCursor, listOwnThreads, listThreads, listedThreadOf, readOwnThread, readThread, setArchived, setFlags, viewerThreads };
@@ -3,7 +3,7 @@
3
3
  import type { R2Bucket } from "@cloudflare/workers-types";
4
4
  import type { AuditEmit } from "@pithy-sh/core/src/audit/recorder";
5
5
  import type { Logger } from "@pithy-sh/core/src/logger/logger";
6
- import type { SupportConfig } from "../config/config";
6
+ import { type SupportConfig } from "../config/config";
7
7
  import { type SupportCategories } from "../data/categories";
8
8
  import { type SupportSubmissionContext } from "../data/message";
9
9
  import { type SupportDatabase } from "../data/tables";
@@ -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;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"}
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,EAAE,KAAK,aAAa,EAAmB,MAAM,kBAAkB,CAAC;AAEvE,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,CA4JjG"}
@@ -1,6 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
  import { SupportInvalidCategoryError, SupportNotFoundError, SupportRejectedError } from "../error/errors.js";
4
+ import { supportSemantic } from "../config/config.js";
4
5
  import { categoryEnum } from "../data/categories.js";
5
6
  import { SupportAttachment } from "../data/attachment.js";
6
7
  import { SupportMessage } from "../data/message.js";
@@ -287,7 +288,7 @@ async function submitFeedback(deps, input) {
287
288
  });
288
289
  }
289
290
  const classifying = deps.config.ai.enabled;
290
- if (classifying) try {
291
+ if (classifying || supportSemantic(deps.config.search) !== void 0) try {
291
292
  await deps.dispatchClassify(messageId);
292
293
  } catch (error) {
293
294
  deps.log.warn("support submission classification dispatch failed", {
@@ -0,0 +1,33 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { D1Database } from "@cloudflare/workers-types";
4
+ import type { SupportChannel, SupportPriority, SupportSentiment } from "../data/enums";
5
+ import { type SupportDatabase } from "../data/tables";
6
+ /** Rows for Workers tests: a clean support schema, a thread, and a message, each with sensible defaults. */
7
+ export declare const SEED_INBOX = "support@help.example.com";
8
+ /** Drop everything and run the whole chain, so each test starts from today's schema. */
9
+ export declare function freshSupportSchema(d1: D1Database): Promise<void>;
10
+ /** Insert one thread. */
11
+ export declare function seedThread(db: SupportDatabase, seed: {
12
+ id: string;
13
+ lastMessageAt: number;
14
+ category?: string;
15
+ declaredCategory?: string;
16
+ priority?: SupportPriority;
17
+ sentiment?: SupportSentiment;
18
+ channel?: SupportChannel;
19
+ inbox?: string;
20
+ archived?: boolean;
21
+ assigneeRef?: string;
22
+ subject?: string;
23
+ }): Promise<void>;
24
+ /** Insert one message. */
25
+ export declare function seedMessage(db: SupportDatabase, seed: {
26
+ id: string;
27
+ threadId: string;
28
+ subject: string;
29
+ body: string;
30
+ receivedAt?: number;
31
+ direction?: "inbound" | "outbound";
32
+ }): Promise<void>;
33
+ //# sourceMappingURL=seed.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seed.d.ts","sourceRoot":"","sources":["../../src/test-utils/seed.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAI5D,OAAO,KAAK,EAAE,cAAc,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAGvF,OAAO,EAAiD,KAAK,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAIrG,4GAA4G;AAE5G,eAAO,MAAM,UAAU,6BAA6B,CAAC;AAgBrD,wFAAwF;AACxF,wBAAsB,kBAAkB,CAAC,EAAE,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAUtE;AAED,yBAAyB;AACzB,wBAAsB,UAAU,CAC9B,EAAE,EAAE,eAAe,EACnB,IAAI,EAAE;IACJ,EAAE,EAAE,MAAM,CAAC;IACX,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,EAAE,eAAe,CAAC;IAC3B,SAAS,CAAC,EAAE,gBAAgB,CAAC;IAC7B,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,GACA,OAAO,CAAC,IAAI,CAAC,CAoCf;AAED,0BAA0B;AAC1B,wBAAsB,WAAW,CAC/B,EAAE,EAAE,eAAe,EACnB,IAAI,EAAE;IACJ,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,SAAS,GAAG,UAAU,CAAC;CACpC,GACA,OAAO,CAAC,IAAI,CAAC,CA8Bf"}
@@ -0,0 +1,102 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import "../data/enums.js";
4
+ import { SupportMessage } from "../data/message.js";
5
+ import { SupportThread } from "../data/thread.js";
6
+ import { SUPPORT_MESSAGES_TABLE, SUPPORT_THREADS_TABLE } from "../data/tables.js";
7
+ import { SUPPORT_MIGRATIONS } from "../migrations/set.js";
8
+ import { SUPPORT_MIGRATION_ORDER } from "../capability.js";
9
+ import { createMigrationRegistry } from "@pithy-sh/core/src/migrations/registry";
10
+ import { runMigrations } from "@pithy-sh/core/src/migrations/runner";
11
+ //#region src/test-utils/seed.ts
12
+ /** Rows for Workers tests: a clean support schema, a thread, and a message, each with sensible defaults. */
13
+ const SEED_INBOX = "support@help.example.com";
14
+ /** Every table a support test may have left behind, children first. */
15
+ const TABLES = [
16
+ "pithy_support_search",
17
+ "pithy_support_embeddings",
18
+ "pithy_support_assignments",
19
+ "pithy_support_thread_flags",
20
+ "pithy_support_classifications",
21
+ "pithy_support_attachments",
22
+ "pithy_support_messages",
23
+ "pithy_support_threads",
24
+ "pithy_migrations",
25
+ "pithy_migrations_lock"
26
+ ];
27
+ /** Drop everything and run the whole chain, so each test starts from today's schema. */
28
+ async function freshSupportSchema(d1) {
29
+ await d1.exec("DROP TRIGGER IF EXISTS pithy_support_search_ai");
30
+ await d1.exec("DROP TRIGGER IF EXISTS pithy_support_search_au");
31
+ await d1.exec("DROP TRIGGER IF EXISTS pithy_support_search_ad");
32
+ for (const table of TABLES) await d1.exec(`DROP TABLE IF EXISTS ${table}`);
33
+ const registry = createMigrationRegistry([{
34
+ database: "app",
35
+ namespace: "support",
36
+ order: SUPPORT_MIGRATION_ORDER,
37
+ migrations: SUPPORT_MIGRATIONS
38
+ }]);
39
+ if (!registry.app) throw new Error("expected a provider for database \"app\"");
40
+ await runMigrations(d1, registry.app);
41
+ }
42
+ /** Insert one thread. */
43
+ async function seedThread(db, seed) {
44
+ const at = new Date(seed.lastMessageAt);
45
+ await db.insertInto(SUPPORT_THREADS_TABLE).values(SupportThread.encode({
46
+ id: seed.id,
47
+ channel: seed.channel ?? "email",
48
+ inboxAddress: seed.inbox ?? "support@help.example.com",
49
+ subject: seed.subject ?? `Thread ${seed.id}`,
50
+ fromAddress: "ada@example.com",
51
+ fromName: null,
52
+ senderAuthenticated: true,
53
+ userId: null,
54
+ declaredCategory: seed.declaredCategory ?? null,
55
+ category: seed.category ?? "uncategorized",
56
+ priority: seed.priority ?? "normal",
57
+ sentiment: seed.sentiment ?? "neutral",
58
+ confidence: null,
59
+ model: null,
60
+ classifiedAt: null,
61
+ archived: seed.archived ?? false,
62
+ archivedAt: null,
63
+ archivedBy: null,
64
+ assigneeRef: seed.assigneeRef ?? null,
65
+ assigneeName: seed.assigneeRef ? `Name ${seed.assigneeRef}` : null,
66
+ assignedAt: seed.assigneeRef ? at : null,
67
+ assignedBy: seed.assigneeRef ? "cp-1" : null,
68
+ messageCount: 1,
69
+ firstMessageAt: at,
70
+ lastMessageAt: at,
71
+ createdAt: at,
72
+ updatedAt: at
73
+ })).execute();
74
+ }
75
+ /** Insert one message. */
76
+ async function seedMessage(db, seed) {
77
+ const at = new Date(seed.receivedAt ?? 17e11);
78
+ await db.insertInto(SUPPORT_MESSAGES_TABLE).values(SupportMessage.encode({
79
+ id: seed.id,
80
+ threadId: seed.threadId,
81
+ direction: seed.direction ?? "inbound",
82
+ channel: "email",
83
+ submittedByUserId: null,
84
+ context: null,
85
+ mimeMessageId: `${seed.id}@mail.example.com`,
86
+ mimeInReplyTo: null,
87
+ mimeReferences: null,
88
+ fromAddress: "ada@example.com",
89
+ fromName: null,
90
+ toAddress: SEED_INBOX,
91
+ subject: seed.subject,
92
+ textBody: seed.body,
93
+ htmlBody: null,
94
+ emailJobId: seed.direction === "outbound" ? `job-${seed.id}` : null,
95
+ rawKey: null,
96
+ rawBytes: null,
97
+ receivedAt: at,
98
+ createdAt: at
99
+ })).execute();
100
+ }
101
+ //#endregion
102
+ export { SEED_INBOX, freshSupportSchema, seedMessage, seedThread };
@@ -0,0 +1,21 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { VectorAi } from "@pithy-sh/vector/src/embed/embed";
4
+ import type { VectorStore, VectorUpsert } from "@pithy-sh/vector/src/index/index";
5
+ /** Embed one text the fake way: concept counts, hashed onto components, unit length. */
6
+ export declare function fakeEmbedding(text: string, dimensions: number): number[];
7
+ /** A fake `AI` binding: records every call and answers the shape a text-embedding model does. */
8
+ export declare function fakeAi(dimensions: number): VectorAi & {
9
+ calls: {
10
+ model: string;
11
+ texts: string[];
12
+ }[];
13
+ fail: boolean;
14
+ };
15
+ /** A fake Vectorize binding: an in-memory map, scored by cosine, with every upsert kept for inspection. */
16
+ export declare function fakeVectorStore(): VectorStore & {
17
+ vectors: Map<string, number[]>;
18
+ upserts: VectorUpsert[][];
19
+ queries: Record<string, unknown>[];
20
+ };
21
+ //# sourceMappingURL=semantic.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"semantic.d.ts","sourceRoot":"","sources":["../../src/test-utils/semantic.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kCAAkC,CAAC;AACjE,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,kCAAkC,CAAC;AA0ClF,wFAAwF;AACxF,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,EAAE,CASxE;AAED,iGAAiG;AACjG,wBAAgB,MAAM,CAAC,UAAU,EAAE,MAAM,GAAG,QAAQ,GAAG;IAAE,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,EAAE,CAAA;KAAE,EAAE,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,CAYpH;AAED,2GAA2G;AAC3G,wBAAgB,eAAe,IAAI,WAAW,GAAG;IAC/C,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IAC/B,OAAO,EAAE,YAAY,EAAE,EAAE,CAAC;IAC1B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;CACpC,CA+BA"}
@@ -0,0 +1,107 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ //#region src/test-utils/semantic.ts
4
+ /**
5
+ * Stand-ins for Workers AI and Vectorize, for tests only. Cloudflare ships no local emulation of either.
6
+ *
7
+ * **The embedder is not random, and that is the point.** It maps words to concepts through a small synonym table
8
+ * and hashes each concept onto a component, so cosine similarity behaves the way a real model's does for the
9
+ * cases the tests state: a paraphrase that shares no word with a message still lands near it, and an order
10
+ * number is just another token. The store scores by cosine and returns ids and scores, as Vectorize does with no
11
+ * payload requested.
12
+ */
13
+ /** Words that mean the same thing to the fake model. Everything else is its own concept. */
14
+ const CONCEPTS = {
15
+ upload: "upload",
16
+ uploads: "upload",
17
+ uploading: "upload",
18
+ file: "upload",
19
+ files: "upload",
20
+ dying: "interrupted",
21
+ dies: "interrupted",
22
+ died: "interrupted",
23
+ timeout: "interrupted",
24
+ times: "interrupted",
25
+ timed: "interrupted",
26
+ halfway: "interrupted",
27
+ out: "interrupted",
28
+ refund: "money",
29
+ charged: "money",
30
+ money: "money"
31
+ };
32
+ function concept(word) {
33
+ return CONCEPTS[word] ?? word;
34
+ }
35
+ function hash(text) {
36
+ let value = 2166136261;
37
+ for (const char of text) value = Math.imul(value ^ char.charCodeAt(0), 16777619);
38
+ return value >>> 0;
39
+ }
40
+ /** Embed one text the fake way: concept counts, hashed onto components, unit length. */
41
+ function fakeEmbedding(text, dimensions) {
42
+ const vector = new Array(dimensions).fill(0);
43
+ const words = text.toLowerCase().match(/[\p{L}\p{N}-]+/gu) ?? [];
44
+ for (const word of words) {
45
+ const at = hash(concept(word)) % dimensions;
46
+ vector[at] = (vector[at] ?? 0) + 1;
47
+ }
48
+ const length = Math.hypot(...vector) || 1;
49
+ return vector.map((component) => component / length);
50
+ }
51
+ /** A fake `AI` binding: records every call and answers the shape a text-embedding model does. */
52
+ function fakeAi(dimensions) {
53
+ const ai = {
54
+ calls: [],
55
+ fail: false,
56
+ async run(model, input) {
57
+ const texts = input.text;
58
+ ai.calls.push({
59
+ model,
60
+ texts
61
+ });
62
+ if (ai.fail) throw new Error("Workers AI is unavailable");
63
+ return {
64
+ shape: [texts.length, dimensions],
65
+ data: texts.map((text) => fakeEmbedding(text, dimensions))
66
+ };
67
+ }
68
+ };
69
+ return ai;
70
+ }
71
+ /** A fake Vectorize binding: an in-memory map, scored by cosine, with every upsert kept for inspection. */
72
+ function fakeVectorStore() {
73
+ const vectors = /* @__PURE__ */ new Map();
74
+ const upserts = [];
75
+ const queries = [];
76
+ let mutation = 0;
77
+ return {
78
+ vectors,
79
+ upserts,
80
+ queries,
81
+ async upsert(batch) {
82
+ upserts.push(batch);
83
+ for (const vector of batch) vectors.set(vector.id, vector.values);
84
+ mutation += 1;
85
+ return { mutationId: `m-${mutation}` };
86
+ },
87
+ async query(vector, options = {}) {
88
+ queries.push(options);
89
+ const topK = Number(options.topK ?? 10);
90
+ const matches = [...vectors.entries()].map(([id, values]) => ({
91
+ id,
92
+ score: values.reduce((sum, value, at) => sum + value * (vector[at] ?? 0), 0)
93
+ })).filter((match) => match.score > 0).sort((a, b) => b.score - a.score).slice(0, topK);
94
+ return {
95
+ count: matches.length,
96
+ matches
97
+ };
98
+ },
99
+ async deleteByIds(ids) {
100
+ for (const id of ids) vectors.delete(id);
101
+ mutation += 1;
102
+ return { mutationId: `m-${mutation}` };
103
+ }
104
+ };
105
+ }
106
+ //#endregion
107
+ export { fakeAi, fakeEmbedding, fakeVectorStore };