@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.
- package/README.md +8 -0
- package/dist/audit/actions.d.ts +9 -0
- package/dist/audit/actions.d.ts.map +1 -1
- package/dist/audit/actions.js +9 -0
- package/dist/capability.d.ts +2 -1
- package/dist/capability.d.ts.map +1 -1
- package/dist/capability.js +30 -6
- package/dist/config/config.d.ts +63 -2
- package/dist/config/config.d.ts.map +1 -1
- package/dist/config/config.js +63 -5
- package/dist/data/assignment.d.ts +43 -0
- package/dist/data/assignment.d.ts.map +1 -0
- package/dist/data/assignment.js +38 -0
- package/dist/data/embedding.d.ts +21 -0
- package/dist/data/embedding.d.ts.map +1 -0
- package/dist/data/embedding.js +23 -0
- package/dist/data/tables.d.ts +28 -0
- package/dist/data/tables.d.ts.map +1 -1
- package/dist/data/tables.js +10 -2
- package/dist/data/thread.d.ts +9 -4
- package/dist/data/thread.d.ts.map +1 -1
- package/dist/data/thread.js +9 -4
- package/dist/http/guards.d.ts +32 -14
- package/dist/http/guards.d.ts.map +1 -1
- package/dist/http/guards.js +44 -16
- package/dist/http/handlers.d.ts +35 -2
- package/dist/http/handlers.d.ts.map +1 -1
- package/dist/http/handlers.js +81 -8
- package/dist/http/resolve.d.ts +8 -0
- package/dist/http/resolve.d.ts.map +1 -1
- package/dist/http/resolve.js +20 -1
- package/dist/http/responses.d.ts +355 -0
- package/dist/http/responses.d.ts.map +1 -1
- package/dist/http/responses.js +64 -2
- package/dist/http/routes.d.ts +25 -11
- package/dist/http/routes.d.ts.map +1 -1
- package/dist/http/routes.js +34 -5
- package/dist/http/schemas.d.ts +67 -3
- package/dist/http/schemas.d.ts.map +1 -1
- package/dist/http/schemas.js +44 -5
- package/dist/http/scopes.d.ts +10 -5
- package/dist/http/scopes.d.ts.map +1 -1
- package/dist/http/scopes.js +42 -6
- package/dist/http/views.d.ts +9 -1
- package/dist/http/views.d.ts.map +1 -1
- package/dist/http/views.js +40 -1
- package/dist/inbound/ingest.d.ts +1 -1
- package/dist/inbound/ingest.d.ts.map +1 -1
- package/dist/inbound/ingest.js +3 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -4
- package/dist/migrations/0002_assignment.d.ts +20 -0
- package/dist/migrations/0002_assignment.d.ts.map +1 -0
- package/dist/migrations/0002_assignment.js +45 -0
- package/dist/migrations/0003_embeddings.d.ts +15 -0
- package/dist/migrations/0003_embeddings.d.ts.map +1 -0
- package/dist/migrations/0003_embeddings.js +23 -0
- package/dist/migrations/set.d.ts +13 -0
- package/dist/migrations/set.d.ts.map +1 -0
- package/dist/migrations/set.js +21 -0
- package/dist/presence/room.d.ts +35 -0
- package/dist/presence/room.d.ts.map +1 -0
- package/dist/presence/room.js +66 -0
- package/dist/provision/provisionSupport.d.ts +32 -0
- package/dist/provision/provisionSupport.d.ts.map +1 -1
- package/dist/provision/provisionSupport.js +10 -2
- package/dist/provision/resolveSupportConfig.d.ts.map +1 -1
- package/dist/provision/resolveSupportConfig.js +13 -2
- package/dist/purge/contribution.d.ts +49 -0
- package/dist/purge/contribution.d.ts.map +1 -0
- package/dist/purge/contribution.js +164 -0
- package/dist/secret/registry.d.ts +26 -2
- package/dist/secret/registry.d.ts.map +1 -1
- package/dist/secret/registry.js +15 -3
- package/dist/semantic/documents.d.ts +21 -0
- package/dist/semantic/documents.d.ts.map +1 -0
- package/dist/semantic/documents.js +128 -0
- package/dist/semantic/embed.d.ts +59 -0
- package/dist/semantic/embed.d.ts.map +1 -0
- package/dist/semantic/embed.js +59 -0
- package/dist/semantic/messageIndex.d.ts +46 -0
- package/dist/semantic/messageIndex.d.ts.map +1 -0
- package/dist/semantic/messageIndex.js +67 -0
- package/dist/semantic/query.d.ts +37 -0
- package/dist/semantic/query.d.ts.map +1 -0
- package/dist/semantic/query.js +46 -0
- package/dist/staff/staff.d.ts +22 -0
- package/dist/staff/staff.d.ts.map +1 -0
- package/dist/staff/staff.js +64 -0
- package/dist/store/assignment.d.ts +63 -0
- package/dist/store/assignment.d.ts.map +1 -0
- package/dist/store/assignment.js +86 -0
- package/dist/store/hybrid.d.ts +134 -0
- package/dist/store/hybrid.d.ts.map +1 -0
- package/dist/store/hybrid.js +185 -0
- package/dist/store/paging.d.ts +10 -0
- package/dist/store/paging.d.ts.map +1 -1
- package/dist/store/paging.js +11 -1
- package/dist/store/provisionIndex.d.ts +21 -0
- package/dist/store/provisionIndex.d.ts.map +1 -0
- package/dist/store/provisionIndex.js +36 -0
- package/dist/store/search.d.ts +18 -9
- package/dist/store/search.d.ts.map +1 -1
- package/dist/store/search.js +36 -34
- package/dist/store/searchIndex.d.ts +4 -4
- package/dist/store/searchIndex.js +4 -4
- package/dist/store/threads.d.ts +174 -8
- package/dist/store/threads.d.ts.map +1 -1
- package/dist/store/threads.js +33 -40
- package/dist/submission/submit.d.ts +1 -1
- package/dist/submission/submit.d.ts.map +1 -1
- package/dist/submission/submit.js +2 -1
- package/dist/test-utils/seed.d.ts +33 -0
- package/dist/test-utils/seed.d.ts.map +1 -0
- package/dist/test-utils/seed.js +102 -0
- package/dist/test-utils/semantic.d.ts +21 -0
- package/dist/test-utils/semantic.d.ts.map +1 -0
- package/dist/test-utils/semantic.js +107 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/workflows/specs.d.ts +23 -0
- package/dist/workflows/specs.d.ts.map +1 -1
- package/dist/workflows/specs.js +27 -7
- package/dist/workflows/worker.d.ts +15 -2
- package/dist/workflows/worker.d.ts.map +1 -1
- package/dist/workflows/worker.js +48 -4
- package/package.json +9 -7
- package/pithy.manifest.json +21 -3
- package/src/audit/actions.ts +9 -0
- package/src/capability.ts +43 -4
- package/src/config/config.ts +104 -5
- package/src/data/assignment.ts +55 -0
- package/src/data/embedding.ts +27 -0
- package/src/data/tables.ts +8 -0
- package/src/data/thread.ts +26 -4
- package/src/http/guards.ts +52 -15
- package/src/http/handlers.ts +125 -4
- package/src/http/resolve.ts +19 -0
- package/src/http/responses.ts +154 -0
- package/src/http/routes.ts +98 -12
- package/src/http/schemas.ts +117 -28
- package/src/http/scopes.ts +43 -6
- package/src/http/views.ts +44 -0
- package/src/inbound/ingest.ts +5 -2
- package/src/index.ts +5 -0
- package/src/migrations/0002_assignment.ts +65 -0
- package/src/migrations/0003_embeddings.ts +29 -0
- package/src/migrations/set.ts +21 -0
- package/src/presence/room.ts +94 -0
- package/src/provision/provisionSupport.ts +33 -1
- package/src/provision/resolveSupportConfig.ts +24 -13
- package/src/purge/contribution.ts +294 -0
- package/src/secret/registry.ts +17 -2
- package/src/semantic/documents.ts +178 -0
- package/src/semantic/embed.ts +99 -0
- package/src/semantic/messageIndex.ts +74 -0
- package/src/semantic/query.ts +66 -0
- package/src/staff/staff.ts +128 -0
- package/src/store/assignment.ts +179 -0
- package/src/store/hybrid.ts +314 -0
- package/src/store/paging.ts +14 -0
- package/src/store/provisionIndex.ts +46 -0
- package/src/store/search.ts +58 -37
- package/src/store/searchIndex.ts +4 -4
- package/src/store/threads.ts +91 -91
- package/src/submission/submit.ts +3 -2
- package/src/test-utils/seed.ts +141 -0
- package/src/test-utils/semantic.ts +110 -0
- package/src/version.generated.ts +1 -1
- package/src/workflows/specs.ts +34 -0
- package/src/workflows/worker.ts +84 -8
- package/src/workflows/wrangler.jsonc +8 -3
package/src/store/threads.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
import type {
|
|
6
|
-
import {
|
|
4
|
+
import type { DatabaseSchema } from "@pithy-sh/core/src/data/db";
|
|
5
|
+
import type { SelectQueryBuilder } from "kysely";
|
|
6
|
+
import { UNASSIGNED } from "../data/assignment";
|
|
7
7
|
import { SupportAttachment } from "../data/attachment";
|
|
8
8
|
import type { SupportChannel, SupportPriority, SupportSentiment } from "../data/enums";
|
|
9
9
|
import { SupportMessage } from "../data/message";
|
|
@@ -11,14 +11,13 @@ import {
|
|
|
11
11
|
SUPPORT_ATTACHMENTS_TABLE,
|
|
12
12
|
SUPPORT_FLAGS_TABLE,
|
|
13
13
|
SUPPORT_MESSAGES_TABLE,
|
|
14
|
-
SUPPORT_SEARCH_TABLE,
|
|
15
14
|
SUPPORT_THREADS_TABLE,
|
|
16
15
|
type SupportDatabase,
|
|
16
|
+
type SupportTables,
|
|
17
17
|
} from "../data/tables";
|
|
18
18
|
import { SupportThread } from "../data/thread";
|
|
19
19
|
import { SupportNotFoundError } from "../error/errors";
|
|
20
20
|
import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from "./paging";
|
|
21
|
-
import { isSearchable, searchPredicate } from "./search";
|
|
22
21
|
|
|
23
22
|
/**
|
|
24
23
|
* The inbox query — the one read this whole data model was shaped around.
|
|
@@ -71,8 +70,12 @@ export function decodeCursor(value: string | undefined): ThreadCursor | undefine
|
|
|
71
70
|
}
|
|
72
71
|
}
|
|
73
72
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
73
|
+
/**
|
|
74
|
+
* What a caller may narrow threads by — the inbox listing and the search route alike. Search applies
|
|
75
|
+
* these in D1, after retrieval, never as Vectorize metadata: metadata would go stale the moment a thread is
|
|
76
|
+
* archived, reassigned or reclassified.
|
|
77
|
+
*/
|
|
78
|
+
export interface ThreadFilters {
|
|
76
79
|
/** Open threads, done threads, or both. Defaults to open. */
|
|
77
80
|
archived?: boolean;
|
|
78
81
|
/** One category key from the effective taxonomy — what the classifier decided. */
|
|
@@ -87,8 +90,17 @@ export interface ListThreadsQuery {
|
|
|
87
90
|
channel?: SupportChannel;
|
|
88
91
|
/** Which inbox address, for a Worker serving more than one. */
|
|
89
92
|
inbox?: string;
|
|
90
|
-
/**
|
|
91
|
-
|
|
93
|
+
/** Whose threads: one assignee's opaque ref, or {@link UNASSIGNED} for the threads nobody has. */
|
|
94
|
+
assignee?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* What a caller may filter the inbox by, and where to resume.
|
|
99
|
+
*
|
|
100
|
+
* **No free text.** Listing lists; `GET {basePath}/threads/search` searches, ranked, with a limit and no
|
|
101
|
+
* cursor. A `q` here could say whether a thread matched, never how well.
|
|
102
|
+
*/
|
|
103
|
+
export interface ListThreadsQuery extends ThreadFilters {
|
|
92
104
|
/** Where to resume from. */
|
|
93
105
|
cursor?: string;
|
|
94
106
|
/** How many to return. */
|
|
@@ -111,6 +123,70 @@ export interface ThreadPage {
|
|
|
111
123
|
nextCursor: string | null;
|
|
112
124
|
}
|
|
113
125
|
|
|
126
|
+
type SupportSchema = DatabaseSchema<SupportTables>;
|
|
127
|
+
type ThreadsAndFlags = typeof SUPPORT_THREADS_TABLE | typeof SUPPORT_FLAGS_TABLE;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Threads joined to one viewer's own flags.
|
|
131
|
+
*
|
|
132
|
+
* Joined on the viewer as well as the thread, so one operator's snooze never hides a thread from anybody
|
|
133
|
+
* else — the whole reason these flags are per viewer rather than on the thread.
|
|
134
|
+
*/
|
|
135
|
+
export function viewerThreads(db: SupportDatabase, viewer: string) {
|
|
136
|
+
return db
|
|
137
|
+
.selectFrom(SUPPORT_THREADS_TABLE)
|
|
138
|
+
.leftJoin(SUPPORT_FLAGS_TABLE, (join) =>
|
|
139
|
+
join
|
|
140
|
+
.onRef(`${SUPPORT_FLAGS_TABLE}.threadId`, "=", `${SUPPORT_THREADS_TABLE}.id`)
|
|
141
|
+
.on(`${SUPPORT_FLAGS_TABLE}.viewer`, "=", viewer),
|
|
142
|
+
)
|
|
143
|
+
.selectAll(SUPPORT_THREADS_TABLE)
|
|
144
|
+
.select([`${SUPPORT_FLAGS_TABLE}.read as viewerRead`, `${SUPPORT_FLAGS_TABLE}.snoozedUntil as viewerSnoozedUntil`]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A row out of {@link viewerThreads}, as the listing and the search both return it. */
|
|
148
|
+
export function listedThreadOf(row: Record<string, unknown>): ListedThread {
|
|
149
|
+
return {
|
|
150
|
+
...SupportThread.parse(row),
|
|
151
|
+
// A viewer with no flag row has read nothing and snoozed nothing, which is the common case and
|
|
152
|
+
// the right default — not an absence a caller has to interpret.
|
|
153
|
+
read: row.viewerRead === 1,
|
|
154
|
+
snoozedUntil: row.viewerSnoozedUntil == null ? null : new Date(Number(row.viewerSnoozedUntil)),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Narrow a {@link viewerThreads} query by every filter the caller set — one statement of them, for both routes. */
|
|
159
|
+
export function applyThreadFilters<O>(
|
|
160
|
+
builder: SelectQueryBuilder<SupportSchema, ThreadsAndFlags, O>,
|
|
161
|
+
filters: ThreadFilters,
|
|
162
|
+
): SelectQueryBuilder<SupportSchema, ThreadsAndFlags, O> {
|
|
163
|
+
// Archived is a filter with a default rather than an optional one: an inbox that showed resolved
|
|
164
|
+
// threads by default would be an inbox nobody trusts to be a work queue.
|
|
165
|
+
let filtered = builder.where(`${SUPPORT_THREADS_TABLE}.archived`, "=", filters.archived === true ? 1 : 0);
|
|
166
|
+
if (filters.category !== undefined)
|
|
167
|
+
filtered = filtered.where(`${SUPPORT_THREADS_TABLE}.category`, "=", filters.category);
|
|
168
|
+
// Its own predicate rather than an `OR` with the one above, because the two answer different
|
|
169
|
+
// questions and an operator asking both is asking for the threads where they agree — which is the
|
|
170
|
+
// one shape a single conflated filter could never express.
|
|
171
|
+
if (filters.declaredCategory !== undefined)
|
|
172
|
+
filtered = filtered.where(`${SUPPORT_THREADS_TABLE}.declaredCategory`, "=", filters.declaredCategory);
|
|
173
|
+
if (filters.priority !== undefined)
|
|
174
|
+
filtered = filtered.where(`${SUPPORT_THREADS_TABLE}.priority`, "=", filters.priority);
|
|
175
|
+
if (filters.sentiment !== undefined)
|
|
176
|
+
filtered = filtered.where(`${SUPPORT_THREADS_TABLE}.sentiment`, "=", filters.sentiment);
|
|
177
|
+
if (filters.channel !== undefined)
|
|
178
|
+
filtered = filtered.where(`${SUPPORT_THREADS_TABLE}.channel`, "=", filters.channel);
|
|
179
|
+
if (filters.inbox !== undefined)
|
|
180
|
+
filtered = filtered.where(`${SUPPORT_THREADS_TABLE}.inboxAddress`, "=", filters.inbox);
|
|
181
|
+
if (filters.assignee !== undefined) {
|
|
182
|
+
filtered =
|
|
183
|
+
filters.assignee === UNASSIGNED
|
|
184
|
+
? filtered.where(`${SUPPORT_THREADS_TABLE}.assigneeRef`, "is", null)
|
|
185
|
+
: filtered.where(`${SUPPORT_THREADS_TABLE}.assigneeRef`, "=", filters.assignee);
|
|
186
|
+
}
|
|
187
|
+
return filtered;
|
|
188
|
+
}
|
|
189
|
+
|
|
114
190
|
/**
|
|
115
191
|
* List threads, as one viewer sees them.
|
|
116
192
|
*
|
|
@@ -122,91 +198,21 @@ export interface ThreadPage {
|
|
|
122
198
|
export async function listThreads(
|
|
123
199
|
db: SupportDatabase,
|
|
124
200
|
query: ListThreadsQuery,
|
|
125
|
-
options: {
|
|
126
|
-
): Promise<ThreadPage> {
|
|
127
|
-
try {
|
|
128
|
-
return await runListThreads(db, query, options);
|
|
129
|
-
} catch (error) {
|
|
130
|
-
// **The index being configured on is not the same as the index existing.**
|
|
131
|
-
//
|
|
132
|
-
// `search.fts` is a config flag while the virtual table is a provisioned resource, and the two can disagree
|
|
133
|
-
// in both directions: a project that turned the flag on but has not migrated yet, a database
|
|
134
|
-
// restored from a snapshot taken before it, a Worker deployed ahead of `pithy migrate`. In every
|
|
135
|
-
// one of those the flag says FTS and the schema says no such table.
|
|
136
|
-
//
|
|
137
|
-
// Falling back to the `LIKE` scan makes that a slower search rather than a 500 on the inbox
|
|
138
|
-
// route. Narrow on purpose: only a missing-table error is caught, so a genuine query bug still
|
|
139
|
-
// surfaces instead of silently degrading forever.
|
|
140
|
-
if (options.fts && isMissingSearchTable(error)) {
|
|
141
|
-
options.log?.warn("support FTS index is configured but absent — falling back to a LIKE scan", {
|
|
142
|
-
action: `Run \`${pithyCommandOf("support provision", {}, { runtime: true })}\` to create and backfill it, or set \`search.fts: false\`.`,
|
|
143
|
-
});
|
|
144
|
-
return runListThreads(db, query, { ...options, fts: false });
|
|
145
|
-
}
|
|
146
|
-
throw error;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** Whether an error is SQLite saying the full-text index does not exist. */
|
|
151
|
-
function isMissingSearchTable(error: unknown): boolean {
|
|
152
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
153
|
-
return message.includes("no such table") && message.includes(SUPPORT_SEARCH_TABLE);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/** The listing itself, for one chosen search backend. */
|
|
157
|
-
async function runListThreads(
|
|
158
|
-
db: SupportDatabase,
|
|
159
|
-
query: ListThreadsQuery,
|
|
160
|
-
options: { fts: boolean; viewer: string; now: Date },
|
|
201
|
+
options: { viewer: string; now: Date },
|
|
161
202
|
): Promise<ThreadPage> {
|
|
162
203
|
const limit = Math.min(Math.max(query.limit ?? DEFAULT_PAGE_SIZE, 1), MAX_PAGE_SIZE);
|
|
163
204
|
const cursor = decodeCursor(query.cursor);
|
|
164
205
|
|
|
165
|
-
let builder = db
|
|
166
|
-
.selectFrom(SUPPORT_THREADS_TABLE)
|
|
167
|
-
// Joined on the viewer as well as the thread, so one operator's snooze never hides a thread from
|
|
168
|
-
// anybody else — the whole reason these flags are per viewer rather than on the thread.
|
|
169
|
-
.leftJoin(SUPPORT_FLAGS_TABLE, (join) =>
|
|
170
|
-
join
|
|
171
|
-
.onRef(`${SUPPORT_FLAGS_TABLE}.threadId`, "=", `${SUPPORT_THREADS_TABLE}.id`)
|
|
172
|
-
.on(`${SUPPORT_FLAGS_TABLE}.viewer`, "=", options.viewer),
|
|
173
|
-
)
|
|
174
|
-
.selectAll(SUPPORT_THREADS_TABLE)
|
|
175
|
-
.select([`${SUPPORT_FLAGS_TABLE}.read as viewerRead`, `${SUPPORT_FLAGS_TABLE}.snoozedUntil as viewerSnoozedUntil`])
|
|
206
|
+
let builder = applyThreadFilters(viewerThreads(db, options.viewer), query)
|
|
176
207
|
// A snooze that has not expired hides the thread from this viewer. Expiry is evaluated on read
|
|
177
|
-
// rather than swept, so nothing has to run for a snooze to end.
|
|
208
|
+
// rather than swept, so nothing has to run for a snooze to end. Listing only: a search finds a
|
|
209
|
+
// snoozed thread, because a snooze hides work for later and never hides an answer.
|
|
178
210
|
.where((eb) =>
|
|
179
211
|
eb.or([
|
|
180
212
|
eb(`${SUPPORT_FLAGS_TABLE}.snoozedUntil`, "is", null),
|
|
181
213
|
eb(`${SUPPORT_FLAGS_TABLE}.snoozedUntil`, "<=", options.now.getTime()),
|
|
182
214
|
]),
|
|
183
|
-
)
|
|
184
|
-
// Archived is a filter with a default rather than an optional one: an inbox that showed resolved
|
|
185
|
-
// threads by default would be an inbox nobody trusts to be a work queue.
|
|
186
|
-
.where(`${SUPPORT_THREADS_TABLE}.archived`, "=", query.archived === true ? 1 : 0);
|
|
187
|
-
|
|
188
|
-
if (query.category !== undefined) builder = builder.where(`${SUPPORT_THREADS_TABLE}.category`, "=", query.category);
|
|
189
|
-
// Its own predicate rather than an `OR` with the one above, because the two answer different
|
|
190
|
-
// questions and an operator asking both is asking for the threads where they agree — which is the
|
|
191
|
-
// one shape a single conflated filter could never express.
|
|
192
|
-
if (query.declaredCategory !== undefined)
|
|
193
|
-
builder = builder.where(`${SUPPORT_THREADS_TABLE}.declaredCategory`, "=", query.declaredCategory);
|
|
194
|
-
if (query.priority !== undefined) builder = builder.where(`${SUPPORT_THREADS_TABLE}.priority`, "=", query.priority);
|
|
195
|
-
if (query.sentiment !== undefined)
|
|
196
|
-
builder = builder.where(`${SUPPORT_THREADS_TABLE}.sentiment`, "=", query.sentiment);
|
|
197
|
-
if (query.channel !== undefined) builder = builder.where(`${SUPPORT_THREADS_TABLE}.channel`, "=", query.channel);
|
|
198
|
-
if (query.inbox !== undefined) builder = builder.where(`${SUPPORT_THREADS_TABLE}.inboxAddress`, "=", query.inbox);
|
|
199
|
-
if (query.q !== undefined) {
|
|
200
|
-
if (isSearchable(query.q, options.fts)) {
|
|
201
|
-
builder = builder.where(searchPredicate(query.q, options));
|
|
202
|
-
} else {
|
|
203
|
-
// A term that tokenizes to nothing — `???`, `@@@` — asked a question with no answer, and the
|
|
204
|
-
// honest answer is none. Dropping the predicate instead would hand back the *entire* unfiltered
|
|
205
|
-
// inbox rendered as search results, which is the one direction a filter must never fail in:
|
|
206
|
-
// silently widening reads as "this is what matched" rather than as an error.
|
|
207
|
-
builder = builder.where(sql<SqlBool>`1 = 0`);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
215
|
+
);
|
|
210
216
|
|
|
211
217
|
if (cursor) {
|
|
212
218
|
// Strictly after the cursor position in the descending order: an older timestamp, or the same
|
|
@@ -231,13 +237,7 @@ async function runListThreads(
|
|
|
231
237
|
.limit(limit + 1)
|
|
232
238
|
.execute();
|
|
233
239
|
|
|
234
|
-
const page: ListedThread[] = rows.slice(0, limit).map(
|
|
235
|
-
...SupportThread.parse(row),
|
|
236
|
-
// A viewer with no flag row has read nothing and snoozed nothing, which is the common case and
|
|
237
|
-
// the right default — not an absence a caller has to interpret.
|
|
238
|
-
read: row.viewerRead === 1,
|
|
239
|
-
snoozedUntil: row.viewerSnoozedUntil == null ? null : new Date(Number(row.viewerSnoozedUntil)),
|
|
240
|
-
}));
|
|
240
|
+
const page: ListedThread[] = rows.slice(0, limit).map(listedThreadOf);
|
|
241
241
|
const last = page[page.length - 1];
|
|
242
242
|
return {
|
|
243
243
|
threads: page,
|
package/src/submission/submit.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { InternalError, ValidationError } from "@pithy-sh/core/src/error/pithyEr
|
|
|
8
8
|
import type { Logger } from "@pithy-sh/core/src/logger/logger";
|
|
9
9
|
import { attachmentKey, putAttachment, sha256Hex } from "../attachment/store";
|
|
10
10
|
import { SupportAuditActions } from "../audit/actions";
|
|
11
|
-
import type
|
|
11
|
+
import { type SupportConfig, supportSemantic } from "../config/config";
|
|
12
12
|
import { SupportAttachment } from "../data/attachment";
|
|
13
13
|
import { categoryEnum, type SupportCategories } from "../data/categories";
|
|
14
14
|
import { SupportMessage, type SupportSubmissionContext } from "../data/message";
|
|
@@ -516,7 +516,8 @@ export async function submitFeedback(deps: SubmitDeps, input: SubmitInput): Prom
|
|
|
516
516
|
|
|
517
517
|
// 6. Classification, last and allowed to fail — the same classifier, the same federated taxonomy.
|
|
518
518
|
const classifying = deps.config.ai.enabled;
|
|
519
|
-
|
|
519
|
+
// The same Workflow embeds the message when semantic search is on.
|
|
520
|
+
if (classifying || supportSemantic(deps.config.search) !== undefined) {
|
|
520
521
|
try {
|
|
521
522
|
await deps.dispatchClassify(messageId);
|
|
522
523
|
} catch (error) {
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import { createMigrationRegistry } from "@pithy-sh/core/src/migrations/registry";
|
|
6
|
+
import { runMigrations } from "@pithy-sh/core/src/migrations/runner";
|
|
7
|
+
import { SUPPORT_MIGRATION_ORDER } from "../capability";
|
|
8
|
+
import type { SupportChannel, SupportPriority, SupportSentiment } from "../data/enums";
|
|
9
|
+
import { UNCATEGORIZED } from "../data/enums";
|
|
10
|
+
import { SupportMessage } from "../data/message";
|
|
11
|
+
import { SUPPORT_MESSAGES_TABLE, SUPPORT_THREADS_TABLE, type SupportDatabase } from "../data/tables";
|
|
12
|
+
import { SupportThread } from "../data/thread";
|
|
13
|
+
import { SUPPORT_MIGRATIONS } from "../migrations/set";
|
|
14
|
+
|
|
15
|
+
/** Rows for Workers tests: a clean support schema, a thread, and a message, each with sensible defaults. */
|
|
16
|
+
|
|
17
|
+
export const SEED_INBOX = "support@help.example.com";
|
|
18
|
+
|
|
19
|
+
/** Every table a support test may have left behind, children first. */
|
|
20
|
+
const TABLES = [
|
|
21
|
+
"pithy_support_search",
|
|
22
|
+
"pithy_support_embeddings",
|
|
23
|
+
"pithy_support_assignments",
|
|
24
|
+
"pithy_support_thread_flags",
|
|
25
|
+
"pithy_support_classifications",
|
|
26
|
+
"pithy_support_attachments",
|
|
27
|
+
"pithy_support_messages",
|
|
28
|
+
"pithy_support_threads",
|
|
29
|
+
"pithy_migrations",
|
|
30
|
+
"pithy_migrations_lock",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/** Drop everything and run the whole chain, so each test starts from today's schema. */
|
|
34
|
+
export async function freshSupportSchema(d1: D1Database): Promise<void> {
|
|
35
|
+
await d1.exec("DROP TRIGGER IF EXISTS pithy_support_search_ai");
|
|
36
|
+
await d1.exec("DROP TRIGGER IF EXISTS pithy_support_search_au");
|
|
37
|
+
await d1.exec("DROP TRIGGER IF EXISTS pithy_support_search_ad");
|
|
38
|
+
for (const table of TABLES) await d1.exec(`DROP TABLE IF EXISTS ${table}`);
|
|
39
|
+
const registry = createMigrationRegistry([
|
|
40
|
+
{ database: "app", namespace: "support", order: SUPPORT_MIGRATION_ORDER, migrations: SUPPORT_MIGRATIONS },
|
|
41
|
+
]);
|
|
42
|
+
if (!registry.app) throw new Error('expected a provider for database "app"');
|
|
43
|
+
await runMigrations(d1, registry.app);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Insert one thread. */
|
|
47
|
+
export async function seedThread(
|
|
48
|
+
db: SupportDatabase,
|
|
49
|
+
seed: {
|
|
50
|
+
id: string;
|
|
51
|
+
lastMessageAt: number;
|
|
52
|
+
category?: string;
|
|
53
|
+
declaredCategory?: string;
|
|
54
|
+
priority?: SupportPriority;
|
|
55
|
+
sentiment?: SupportSentiment;
|
|
56
|
+
channel?: SupportChannel;
|
|
57
|
+
inbox?: string;
|
|
58
|
+
archived?: boolean;
|
|
59
|
+
assigneeRef?: string;
|
|
60
|
+
subject?: string;
|
|
61
|
+
},
|
|
62
|
+
): Promise<void> {
|
|
63
|
+
const at = new Date(seed.lastMessageAt);
|
|
64
|
+
await db
|
|
65
|
+
.insertInto(SUPPORT_THREADS_TABLE)
|
|
66
|
+
.values(
|
|
67
|
+
SupportThread.encode({
|
|
68
|
+
id: seed.id,
|
|
69
|
+
channel: seed.channel ?? "email",
|
|
70
|
+
inboxAddress: seed.inbox ?? SEED_INBOX,
|
|
71
|
+
subject: seed.subject ?? `Thread ${seed.id}`,
|
|
72
|
+
fromAddress: "ada@example.com",
|
|
73
|
+
fromName: null,
|
|
74
|
+
senderAuthenticated: true,
|
|
75
|
+
userId: null,
|
|
76
|
+
declaredCategory: seed.declaredCategory ?? null,
|
|
77
|
+
category: seed.category ?? UNCATEGORIZED,
|
|
78
|
+
priority: seed.priority ?? "normal",
|
|
79
|
+
sentiment: seed.sentiment ?? "neutral",
|
|
80
|
+
confidence: null,
|
|
81
|
+
model: null,
|
|
82
|
+
classifiedAt: null,
|
|
83
|
+
archived: seed.archived ?? false,
|
|
84
|
+
archivedAt: null,
|
|
85
|
+
archivedBy: null,
|
|
86
|
+
assigneeRef: seed.assigneeRef ?? null,
|
|
87
|
+
assigneeName: seed.assigneeRef ? `Name ${seed.assigneeRef}` : null,
|
|
88
|
+
assignedAt: seed.assigneeRef ? at : null,
|
|
89
|
+
assignedBy: seed.assigneeRef ? "cp-1" : null,
|
|
90
|
+
messageCount: 1,
|
|
91
|
+
firstMessageAt: at,
|
|
92
|
+
lastMessageAt: at,
|
|
93
|
+
createdAt: at,
|
|
94
|
+
updatedAt: at,
|
|
95
|
+
}),
|
|
96
|
+
)
|
|
97
|
+
.execute();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Insert one message. */
|
|
101
|
+
export async function seedMessage(
|
|
102
|
+
db: SupportDatabase,
|
|
103
|
+
seed: {
|
|
104
|
+
id: string;
|
|
105
|
+
threadId: string;
|
|
106
|
+
subject: string;
|
|
107
|
+
body: string;
|
|
108
|
+
receivedAt?: number;
|
|
109
|
+
direction?: "inbound" | "outbound";
|
|
110
|
+
},
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
const at = new Date(seed.receivedAt ?? 1_700_000_000_000);
|
|
113
|
+
await db
|
|
114
|
+
.insertInto(SUPPORT_MESSAGES_TABLE)
|
|
115
|
+
.values(
|
|
116
|
+
SupportMessage.encode({
|
|
117
|
+
id: seed.id,
|
|
118
|
+
threadId: seed.threadId,
|
|
119
|
+
direction: seed.direction ?? "inbound",
|
|
120
|
+
channel: "email" as const,
|
|
121
|
+
submittedByUserId: null,
|
|
122
|
+
context: null,
|
|
123
|
+
mimeMessageId: `${seed.id}@mail.example.com`,
|
|
124
|
+
mimeInReplyTo: null,
|
|
125
|
+
mimeReferences: null,
|
|
126
|
+
fromAddress: "ada@example.com",
|
|
127
|
+
fromName: null,
|
|
128
|
+
toAddress: SEED_INBOX,
|
|
129
|
+
subject: seed.subject,
|
|
130
|
+
textBody: seed.body,
|
|
131
|
+
htmlBody: null,
|
|
132
|
+
// An outbound mail row names the job it was enqueued as; the schema refuses one that does not.
|
|
133
|
+
emailJobId: seed.direction === "outbound" ? `job-${seed.id}` : null,
|
|
134
|
+
rawKey: null,
|
|
135
|
+
rawBytes: null,
|
|
136
|
+
receivedAt: at,
|
|
137
|
+
createdAt: at,
|
|
138
|
+
}),
|
|
139
|
+
)
|
|
140
|
+
.execute();
|
|
141
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { VectorAi } from "@pithy-sh/vector/src/embed/embed";
|
|
5
|
+
import type { VectorStore, VectorUpsert } from "@pithy-sh/vector/src/index/index";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Stand-ins for Workers AI and Vectorize, for tests only. Cloudflare ships no local emulation of either.
|
|
9
|
+
*
|
|
10
|
+
* **The embedder is not random, and that is the point.** It maps words to concepts through a small synonym table
|
|
11
|
+
* and hashes each concept onto a component, so cosine similarity behaves the way a real model's does for the
|
|
12
|
+
* cases the tests state: a paraphrase that shares no word with a message still lands near it, and an order
|
|
13
|
+
* number is just another token. The store scores by cosine and returns ids and scores, as Vectorize does with no
|
|
14
|
+
* payload requested.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Words that mean the same thing to the fake model. Everything else is its own concept. */
|
|
18
|
+
const CONCEPTS: Record<string, string> = {
|
|
19
|
+
upload: "upload",
|
|
20
|
+
uploads: "upload",
|
|
21
|
+
uploading: "upload",
|
|
22
|
+
file: "upload",
|
|
23
|
+
files: "upload",
|
|
24
|
+
dying: "interrupted",
|
|
25
|
+
dies: "interrupted",
|
|
26
|
+
died: "interrupted",
|
|
27
|
+
timeout: "interrupted",
|
|
28
|
+
times: "interrupted",
|
|
29
|
+
timed: "interrupted",
|
|
30
|
+
halfway: "interrupted",
|
|
31
|
+
out: "interrupted",
|
|
32
|
+
refund: "money",
|
|
33
|
+
charged: "money",
|
|
34
|
+
money: "money",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function concept(word: string): string {
|
|
38
|
+
return CONCEPTS[word] ?? word;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function hash(text: string): number {
|
|
42
|
+
let value = 2166136261;
|
|
43
|
+
for (const char of text) value = Math.imul(value ^ char.charCodeAt(0), 16777619);
|
|
44
|
+
return value >>> 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Embed one text the fake way: concept counts, hashed onto components, unit length. */
|
|
48
|
+
export function fakeEmbedding(text: string, dimensions: number): number[] {
|
|
49
|
+
const vector = new Array<number>(dimensions).fill(0);
|
|
50
|
+
const words = text.toLowerCase().match(/[\p{L}\p{N}-]+/gu) ?? [];
|
|
51
|
+
for (const word of words) {
|
|
52
|
+
const at = hash(concept(word)) % dimensions;
|
|
53
|
+
vector[at] = (vector[at] ?? 0) + 1;
|
|
54
|
+
}
|
|
55
|
+
const length = Math.hypot(...vector) || 1;
|
|
56
|
+
return vector.map((component) => component / length);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A fake `AI` binding: records every call and answers the shape a text-embedding model does. */
|
|
60
|
+
export function fakeAi(dimensions: number): VectorAi & { calls: { model: string; texts: string[] }[]; fail: boolean } {
|
|
61
|
+
const ai = {
|
|
62
|
+
calls: [] as { model: string; texts: string[] }[],
|
|
63
|
+
fail: false,
|
|
64
|
+
async run(model: string, input: Record<string, unknown>) {
|
|
65
|
+
const texts = input.text as string[];
|
|
66
|
+
ai.calls.push({ model, texts });
|
|
67
|
+
if (ai.fail) throw new Error("Workers AI is unavailable");
|
|
68
|
+
return { shape: [texts.length, dimensions], data: texts.map((text) => fakeEmbedding(text, dimensions)) };
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
return ai;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A fake Vectorize binding: an in-memory map, scored by cosine, with every upsert kept for inspection. */
|
|
75
|
+
export function fakeVectorStore(): VectorStore & {
|
|
76
|
+
vectors: Map<string, number[]>;
|
|
77
|
+
upserts: VectorUpsert[][];
|
|
78
|
+
queries: Record<string, unknown>[];
|
|
79
|
+
} {
|
|
80
|
+
const vectors = new Map<string, number[]>();
|
|
81
|
+
const upserts: VectorUpsert[][] = [];
|
|
82
|
+
const queries: Record<string, unknown>[] = [];
|
|
83
|
+
let mutation = 0;
|
|
84
|
+
return {
|
|
85
|
+
vectors,
|
|
86
|
+
upserts,
|
|
87
|
+
queries,
|
|
88
|
+
async upsert(batch) {
|
|
89
|
+
upserts.push(batch);
|
|
90
|
+
for (const vector of batch) vectors.set(vector.id, vector.values);
|
|
91
|
+
mutation += 1;
|
|
92
|
+
return { mutationId: `m-${mutation}` };
|
|
93
|
+
},
|
|
94
|
+
async query(vector, options = {}) {
|
|
95
|
+
queries.push(options);
|
|
96
|
+
const topK = Number(options.topK ?? 10);
|
|
97
|
+
const matches = [...vectors.entries()]
|
|
98
|
+
.map(([id, values]) => ({ id, score: values.reduce((sum, value, at) => sum + value * (vector[at] ?? 0), 0) }))
|
|
99
|
+
.filter((match) => match.score > 0)
|
|
100
|
+
.sort((a, b) => b.score - a.score)
|
|
101
|
+
.slice(0, topK);
|
|
102
|
+
return { count: matches.length, matches };
|
|
103
|
+
},
|
|
104
|
+
async deleteByIds(ids) {
|
|
105
|
+
for (const id of ids) vectors.delete(id);
|
|
106
|
+
mutation += 1;
|
|
107
|
+
return { mutationId: `m-${mutation}` };
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
package/src/version.generated.ts
CHANGED
package/src/workflows/specs.ts
CHANGED
|
@@ -37,6 +37,32 @@ export const SupportClassifyParams = z
|
|
|
37
37
|
.describe("The instance parameters of the classification Workflow — the one message it runs against.");
|
|
38
38
|
export type SupportClassifyParams = z.infer<typeof SupportClassifyParams>;
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* One re-embed of the semantic message index, started by `pithy vector reprocess --index support-messages`.
|
|
42
|
+
*
|
|
43
|
+
* The parameters are the ones `pithy vector reprocess` sends every index, so the CLI dispatches a capability's
|
|
44
|
+
* index exactly as it dispatches one `vector()` owns. There is no `filter`: the vectors carry no metadata to
|
|
45
|
+
* filter on.
|
|
46
|
+
*/
|
|
47
|
+
export const SupportReprocessParams = z
|
|
48
|
+
.object({
|
|
49
|
+
index: z.literal("support-messages").describe("The index to re-embed. Support declares one, `support-messages`."),
|
|
50
|
+
all: z
|
|
51
|
+
.boolean()
|
|
52
|
+
.optional()
|
|
53
|
+
.describe(
|
|
54
|
+
"Re-embed every inbound message, not only the ones whose stamp differs from the configured model. What a fresh index's backfill runs.",
|
|
55
|
+
),
|
|
56
|
+
pageSize: z
|
|
57
|
+
.number()
|
|
58
|
+
.int()
|
|
59
|
+
.min(1)
|
|
60
|
+
.optional()
|
|
61
|
+
.describe("Messages per Workflow step. Defaults to the Vectorize upsert ceiling of 1,000."),
|
|
62
|
+
})
|
|
63
|
+
.describe("The instance parameters of a re-embed of the support message index.");
|
|
64
|
+
export type SupportReprocessParams = z.infer<typeof SupportReprocessParams>;
|
|
65
|
+
|
|
40
66
|
/** Support's durable jobs, keyed by job name. The key is the second segment of the `support/<job>` dispatch key. */
|
|
41
67
|
export const supportWorkflows = {
|
|
42
68
|
classify: {
|
|
@@ -45,6 +71,14 @@ export const supportWorkflows = {
|
|
|
45
71
|
params: SupportClassifyParams,
|
|
46
72
|
optional: true,
|
|
47
73
|
},
|
|
74
|
+
// Hosted beside classification, in the same prebuilt worker, because it needs the same three things: the
|
|
75
|
+
// app database, the `AI` binding, and the message index. Optional for the same reason classify is.
|
|
76
|
+
reprocess: {
|
|
77
|
+
binding: "SUPPORT_REPROCESS",
|
|
78
|
+
className: "SupportReprocessWorkflow",
|
|
79
|
+
params: SupportReprocessParams,
|
|
80
|
+
optional: true,
|
|
81
|
+
},
|
|
48
82
|
} as const satisfies WorkflowSpecMap;
|
|
49
83
|
|
|
50
84
|
/**
|