@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
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { SupportAssignment } from "../data/assignment.js";
|
|
4
|
+
import { SupportAttachment } from "../data/attachment.js";
|
|
5
|
+
import { SupportMessage } from "../data/message.js";
|
|
6
|
+
import { SupportThread } from "../data/thread.js";
|
|
7
|
+
import { SUPPORT_ASSIGNMENTS_TABLE, SUPPORT_ATTACHMENTS_TABLE, SUPPORT_CLASSIFICATIONS_TABLE, SUPPORT_EMBEDDINGS_TABLE, SUPPORT_FLAGS_TABLE, SUPPORT_MESSAGES_TABLE, SUPPORT_THREADS_TABLE, supportDatabase } from "../data/tables.js";
|
|
8
|
+
import { SUPPORT_BUCKET_BINDING } from "../secret/registry.js";
|
|
9
|
+
import { SUPPORT_VECTORIZE_BINDING } from "../semantic/messageIndex.js";
|
|
10
|
+
import { normalizeAddress } from "@pithy-sh/core/src/address/address";
|
|
11
|
+
import { sql } from "kysely";
|
|
12
|
+
import { deleteVectors } from "@pithy-sh/vector/src/index/index";
|
|
13
|
+
import { exportRows, purgeBinding } from "@pithy-sh/core/src/capability/purge";
|
|
14
|
+
//#region src/purge/contribution.ts
|
|
15
|
+
/**
|
|
16
|
+
* What erasing one person means for `@pithy-sh/support` (#814): every thread they opened — linked to their account,
|
|
17
|
+
* or sent from their address and linked to no account — with every message in it, its attachments and raw mail in
|
|
18
|
+
* R2, its classifications, every operator's read and snooze state on it, and the history of who had it (#816). And
|
|
19
|
+
* what exporting them hands back.
|
|
20
|
+
*
|
|
21
|
+
* **A thread is the person's when it is linked to their account or came from their address.** An email thread from
|
|
22
|
+
* someone who never signed in carries no `userId`, only the address it came from; the address is read from the
|
|
23
|
+
* account that owns it (`PurgeContext.address`), so a thread sent before the person had an account is theirs too.
|
|
24
|
+
* The address side takes only a thread linked to no account: an address can move, and a thread linked to whoever
|
|
25
|
+
* held it before is that account's, never handed to the person who holds it now, nor erased with them.
|
|
26
|
+
*
|
|
27
|
+
* **The bytes go first, then the rows.** An attachment's row and a message's `rawKey` are the only things that name
|
|
28
|
+
* their objects, so a row deleted ahead of its object would leave bytes nothing can find. An R2 delete of a key
|
|
29
|
+
* already gone succeeds, so a retried step is **idempotent**, and a person with no threads finds nothing. Deleting
|
|
30
|
+
* a message drops it from the full-text index through the index's own triggers. Its vector in the semantic index
|
|
31
|
+
* is deleted by the message's id, stamped or not, before the message row that names it.
|
|
32
|
+
*
|
|
33
|
+
* **Who had a thread goes with the thread; who handed it over is the operator's.** The assignment history is a
|
|
34
|
+
* thread's own record — each change names the thread, who had it before and after, and the assigner's note about
|
|
35
|
+
* the person's request — so it is erased with the thread and exported with it, oldest first. Who made each change,
|
|
36
|
+
* `assignedBy`, is the operator's subject or a staff member's id, not the person's: the export leaves it out of the
|
|
37
|
+
* history and of the thread, as leaderboard leaves out a moderator, and the audit trail keeps every handover with its
|
|
38
|
+
* actor.
|
|
39
|
+
*
|
|
40
|
+
* **A staff member's own handovers, on threads that are not theirs, lose their id.** On the staff session door,
|
|
41
|
+
* `assignedBy` is the signed-in user's id, written onto a customer's thread and its history. Their purge sets it to
|
|
42
|
+
* `ERASED_ASSIGNER` wherever they acted as a user — every history row with `actorType = 'user'` and their id, and the
|
|
43
|
+
* thread's own `assignedBy` when that row made its current assignment — so the handover stays in the customer's
|
|
44
|
+
* record and stops naming them. A control-plane subject that happens to share the id is a client, not the person, and
|
|
45
|
+
* is left alone. Their export hands those handovers back as which thread, when, and how they were verified, never the
|
|
46
|
+
* assignee or the note, which are about the customer's request. Who **held** a thread is kept: an assignee is an
|
|
47
|
+
* opaque reference and a name the assigner chose, support cannot tell which reference is the person, and both go
|
|
48
|
+
* when the customer's thread does.
|
|
49
|
+
*/
|
|
50
|
+
/** What a purged staff member's id becomes on the handovers they made (#816). */
|
|
51
|
+
const ERASED_ASSIGNER = "erased-user";
|
|
52
|
+
/** The one sentence the purge records with this step. */
|
|
53
|
+
const SUPPORT_PURGE_SUMMARY = "Deletes every support thread the user opened, linked to their account or sent from their address and linked to no other account, with its messages, attachments, raw mail, classifications, read state and assignment history. On other people's threads, the handovers they made as a signed-in user lose their id; an assignee is an opaque reference the assigner named, so who held a thread stays until that thread goes.";
|
|
54
|
+
/** How many threads one read takes, so a person with a long history is erased in bounded statements. */
|
|
55
|
+
const PAGE = 50;
|
|
56
|
+
/** R2's ceiling on the keys one `delete` takes. */
|
|
57
|
+
const R2_DELETE_KEYS = 1e3;
|
|
58
|
+
/** Vector ids one `deleteByIds` call takes — Vectorize's own write batch, the same ceiling an upsert has. */
|
|
59
|
+
const VECTOR_DELETE_IDS = 1e3;
|
|
60
|
+
/**
|
|
61
|
+
* Which threads are the person's: linked to the account, or sent from its address and linked to no account. Both
|
|
62
|
+
* sides are `normalizeAddress`'s form — a thread's `fromAddress` is normalized on the way in — so the index answers.
|
|
63
|
+
* It binds at most two parameters, however many threads it matches, so a statement built on it stays under D1's cap.
|
|
64
|
+
*/
|
|
65
|
+
async function theirs(context) {
|
|
66
|
+
const read = await context.address?.() ?? null;
|
|
67
|
+
const address = read === null ? null : normalizeAddress(read);
|
|
68
|
+
const userId = context.subject.id;
|
|
69
|
+
return (eb) => address === null ? eb("userId", "=", userId) : eb.or([eb("userId", "=", userId), eb.and([eb("fromAddress", "=", address), eb("userId", "is", null)])]);
|
|
70
|
+
}
|
|
71
|
+
/** One handover a staff member made, as their export carries it: which thread, when, and how they were verified. */
|
|
72
|
+
const SupportHandover = SupportAssignment.pick({
|
|
73
|
+
id: true,
|
|
74
|
+
threadId: true,
|
|
75
|
+
actorType: true,
|
|
76
|
+
assignedBy: true,
|
|
77
|
+
createdAt: true
|
|
78
|
+
});
|
|
79
|
+
/** A row without who made its assignment: the operator's, never the person's (#816). */
|
|
80
|
+
function withoutAssigner(row) {
|
|
81
|
+
const { assignedBy: _assigner, ...rest } = row;
|
|
82
|
+
return rest;
|
|
83
|
+
}
|
|
84
|
+
/** The person's thread ids, oldest first, at most `limit`. */
|
|
85
|
+
async function threadIds(db, predicate, limit) {
|
|
86
|
+
return (await db.selectFrom(SUPPORT_THREADS_TABLE).select("id").where(predicate).orderBy("createdAt").orderBy("id").limit(limit).execute()).map((row) => row.id);
|
|
87
|
+
}
|
|
88
|
+
/** The user contribution `support()` declares. */
|
|
89
|
+
function supportUserPurge(database = "DB", options = {}) {
|
|
90
|
+
const open = (env) => {
|
|
91
|
+
const d1 = purgeBinding(env, database, "support");
|
|
92
|
+
return {
|
|
93
|
+
d1,
|
|
94
|
+
db: supportDatabase(d1)
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
return {
|
|
98
|
+
subject: "user",
|
|
99
|
+
summary: SUPPORT_PURGE_SUMMARY,
|
|
100
|
+
erase: async (context) => {
|
|
101
|
+
const { d1, db } = open(context.env);
|
|
102
|
+
const predicate = await theirs(context);
|
|
103
|
+
const userId = context.subject.id;
|
|
104
|
+
const anonymize = [db.updateTable(SUPPORT_THREADS_TABLE).set({ assignedBy: ERASED_ASSIGNER }).where("assignedBy", "=", userId).where((eb) => eb.exists(eb.selectFrom(SUPPORT_ASSIGNMENTS_TABLE).select(sql.lit(1).as("one")).whereRef(`${SUPPORT_ASSIGNMENTS_TABLE}.threadId`, "=", `${SUPPORT_THREADS_TABLE}.id`).whereRef(`${SUPPORT_ASSIGNMENTS_TABLE}.createdAt`, "=", `${SUPPORT_THREADS_TABLE}.assignedAt`).where(`${SUPPORT_ASSIGNMENTS_TABLE}.actorType`, "=", "user").where(`${SUPPORT_ASSIGNMENTS_TABLE}.assignedBy`, "=", userId))).compile(), db.updateTable(SUPPORT_ASSIGNMENTS_TABLE).set({ assignedBy: ERASED_ASSIGNER }).where("actorType", "=", "user").where("assignedBy", "=", userId).compile()];
|
|
105
|
+
await d1.batch(anonymize.map((compiled) => d1.prepare(compiled.sql).bind(...compiled.parameters)));
|
|
106
|
+
let bucket;
|
|
107
|
+
let vectors;
|
|
108
|
+
for (;;) {
|
|
109
|
+
const ids = await threadIds(db, predicate, PAGE);
|
|
110
|
+
if (ids.length === 0) return;
|
|
111
|
+
const [attachments, messages] = await Promise.all([db.selectFrom(SUPPORT_ATTACHMENTS_TABLE).select("storageKey").where("threadId", "in", ids).execute(), db.selectFrom(SUPPORT_MESSAGES_TABLE).select(["id", "rawKey"]).where("threadId", "in", ids).execute()]);
|
|
112
|
+
const keys = [...attachments.map((row) => row.storageKey), ...messages.map((row) => row.rawKey).filter((key) => typeof key === "string" && key !== "")];
|
|
113
|
+
if (keys.length > 0) {
|
|
114
|
+
bucket ??= purgeBinding(context.env, SUPPORT_BUCKET_BINDING, "support", "R2 bucket");
|
|
115
|
+
for (let start = 0; start < keys.length; start += R2_DELETE_KEYS) await bucket.delete(keys.slice(start, start + R2_DELETE_KEYS));
|
|
116
|
+
}
|
|
117
|
+
if (vectors === void 0 && context.env["SUPPORT_VECTORIZE"] === void 0) {
|
|
118
|
+
if (options.semantic === true || await db.selectFrom("pithySupportEmbeddings").select("messageId").where("messageId", "in", (eb) => eb.selectFrom("pithySupportMessages").select("id").where("threadId", "in", ids)).executeTakeFirst() !== void 0) vectors = purgeBinding(context.env, SUPPORT_VECTORIZE_BINDING, "support", "Vectorize index");
|
|
119
|
+
} else vectors ??= purgeBinding(context.env, SUPPORT_VECTORIZE_BINDING, "support", "Vectorize index");
|
|
120
|
+
if (vectors) {
|
|
121
|
+
const vectorIds = messages.map((row) => row.id);
|
|
122
|
+
for (let start = 0; start < vectorIds.length; start += VECTOR_DELETE_IDS) await deleteVectors(vectors, vectorIds.slice(start, start + VECTOR_DELETE_IDS));
|
|
123
|
+
}
|
|
124
|
+
const statements = [
|
|
125
|
+
db.deleteFrom(SUPPORT_EMBEDDINGS_TABLE).where("messageId", "in", (eb) => eb.selectFrom(SUPPORT_MESSAGES_TABLE).select("id").where("threadId", "in", ids)).compile(),
|
|
126
|
+
db.deleteFrom(SUPPORT_ATTACHMENTS_TABLE).where("threadId", "in", ids).compile(),
|
|
127
|
+
db.deleteFrom(SUPPORT_ASSIGNMENTS_TABLE).where("threadId", "in", ids).compile(),
|
|
128
|
+
db.deleteFrom(SUPPORT_CLASSIFICATIONS_TABLE).where("threadId", "in", ids).compile(),
|
|
129
|
+
db.deleteFrom(SUPPORT_FLAGS_TABLE).where("threadId", "in", ids).compile(),
|
|
130
|
+
db.deleteFrom(SUPPORT_MESSAGES_TABLE).where("threadId", "in", ids).compile(),
|
|
131
|
+
db.deleteFrom(SUPPORT_THREADS_TABLE).where("id", "in", ids).compile()
|
|
132
|
+
];
|
|
133
|
+
await d1.batch(statements.map((compiled) => d1.prepare(compiled.sql).bind(...compiled.parameters)));
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
export: async (context) => {
|
|
137
|
+
const { db } = open(context.env);
|
|
138
|
+
const predicate = await theirs(context);
|
|
139
|
+
const threadIdsOf = db.selectFrom(SUPPORT_THREADS_TABLE).select("id").where(predicate);
|
|
140
|
+
const [threads, messages, attachments, assignments, handovers] = await Promise.all([
|
|
141
|
+
db.selectFrom(SUPPORT_THREADS_TABLE).selectAll().where(predicate).orderBy("createdAt").orderBy("id").execute(),
|
|
142
|
+
db.selectFrom(SUPPORT_MESSAGES_TABLE).selectAll().where("threadId", "in", threadIdsOf).orderBy("receivedAt").orderBy("id").execute(),
|
|
143
|
+
db.selectFrom(SUPPORT_ATTACHMENTS_TABLE).selectAll().where("threadId", "in", threadIdsOf).orderBy("createdAt").orderBy("id").execute(),
|
|
144
|
+
db.selectFrom(SUPPORT_ASSIGNMENTS_TABLE).selectAll().where("threadId", "in", threadIdsOf).orderBy("createdAt").orderBy("id").execute(),
|
|
145
|
+
db.selectFrom(SUPPORT_ASSIGNMENTS_TABLE).select([
|
|
146
|
+
"id",
|
|
147
|
+
"threadId",
|
|
148
|
+
"actorType",
|
|
149
|
+
"assignedBy",
|
|
150
|
+
"createdAt"
|
|
151
|
+
]).where("actorType", "=", "user").where("assignedBy", "=", context.subject.id).orderBy("threadId").orderBy("createdAt").orderBy("id").execute()
|
|
152
|
+
]);
|
|
153
|
+
return {
|
|
154
|
+
threads: exportRows(threads.map((row) => withoutAssigner(SupportThread.parse(row)))),
|
|
155
|
+
messages: exportRows(messages.map((row) => SupportMessage.parse(row))),
|
|
156
|
+
attachments: exportRows(attachments.map((row) => SupportAttachment.parse(row))),
|
|
157
|
+
assignments: exportRows(assignments.map((row) => withoutAssigner(SupportAssignment.parse(row)))),
|
|
158
|
+
handovers: exportRows(handovers.map((row) => SupportHandover.parse(row)))
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
//#endregion
|
|
164
|
+
export { ERASED_ASSIGNER, SUPPORT_PURGE_SUMMARY, supportUserPurge };
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
import { type SupportConfig } from "../config/config";
|
|
4
4
|
/**
|
|
5
|
-
* The secrets support reads —
|
|
5
|
+
* The secrets support reads — two, and neither is support's own shape: the R2 credential bundle
|
|
6
|
+
* attachments are signed with, and the presence ticket key its `support.thread` room is joined with.
|
|
6
7
|
*
|
|
7
8
|
* Attachment bytes are written through the R2 binding and served as short-lived signed URLs, and
|
|
8
9
|
* signing needs an S3 key pair. Rather than declare another credential shape, support declares the
|
|
@@ -26,8 +27,31 @@ export declare const SUPPORT_R2_SECRET = "support-r2-credentials";
|
|
|
26
27
|
* reads the decline the bindings side already reports.
|
|
27
28
|
*/
|
|
28
29
|
export declare const SUPPORT_BUCKET_BINDING = "SUPPORT_BUCKET";
|
|
30
|
+
/**
|
|
31
|
+
* The presence ticket key (#816), declared exactly as core states it. The name is the join key, so every
|
|
32
|
+
* capability in a Worker that declares rooms signs and verifies with the one key, and nobody chooses its
|
|
33
|
+
* value: `pithy add` mints the local one and `pithy secrets provision` the deployed ones.
|
|
34
|
+
*/
|
|
35
|
+
export declare const supportPresenceRegistry: {
|
|
36
|
+
readonly "presence-ticket-key": {
|
|
37
|
+
readonly backend: "d1";
|
|
38
|
+
readonly scope: "environment";
|
|
39
|
+
readonly rotatable: true;
|
|
40
|
+
readonly valueType: "text";
|
|
41
|
+
readonly devValue: "random";
|
|
42
|
+
};
|
|
43
|
+
};
|
|
29
44
|
/** The support capability's secret-registry slice — aggregated into the shared accessor at startup. */
|
|
30
|
-
export declare const supportSecretsRegistry:
|
|
45
|
+
export declare const supportSecretsRegistry: {
|
|
46
|
+
readonly "presence-ticket-key": {
|
|
47
|
+
readonly backend: "d1";
|
|
48
|
+
readonly scope: "environment";
|
|
49
|
+
readonly rotatable: true;
|
|
50
|
+
readonly valueType: "text";
|
|
51
|
+
readonly devValue: "random";
|
|
52
|
+
};
|
|
53
|
+
readonly "support-r2-credentials": import("@pithy-sh/storage/src/secret/registry").R2CredentialsEntry;
|
|
54
|
+
};
|
|
31
55
|
/**
|
|
32
56
|
* **The credential this composition cannot reach**, as the `inapplicableSecrets` contribution (#541).
|
|
33
57
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/secret/registry.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/secret/registry.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,KAAK,aAAa,EAAsB,MAAM,kBAAkB,CAAC;AAE1E;;;;;;;;;;;;;GAaG;AAEH,6FAA6F;AAC7F,eAAO,MAAM,iBAAiB,2BAA2B,CAAC;AAE1D;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB,mBAAmB,CAAC;AAEvD;;;;GAIG;AACH,eAAO,MAAM,uBAAuB;;;;;;;;CAElC,CAAC;AAEH,uGAAuG;AACvG,eAAO,MAAM,sBAAsB;;;;;;;;;CAGjC,CAAC;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,6BAA6B,CAAC,MAAM,EAAE,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAM3F"}
|
package/dist/secret/registry.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
import { supportNeedsBucket } from "../config/config.js";
|
|
4
|
+
import { PRESENCE_TICKET_KEY_ENTRY, PRESENCE_TICKET_KEY_SECRET } from "@pithy-sh/core/src/presence/declaration";
|
|
5
|
+
import { defineSecretRegistry } from "@pithy-sh/secrets/src/registry";
|
|
4
6
|
import { r2CredentialsRegistry } from "@pithy-sh/storage/src/secret/registry";
|
|
5
7
|
//#region src/secret/registry.ts
|
|
6
8
|
/**
|
|
7
|
-
* The secrets support reads —
|
|
9
|
+
* The secrets support reads — two, and neither is support's own shape: the R2 credential bundle
|
|
10
|
+
* attachments are signed with, and the presence ticket key its `support.thread` room is joined with.
|
|
8
11
|
*
|
|
9
12
|
* Attachment bytes are written through the R2 binding and served as short-lived signed URLs, and
|
|
10
13
|
* signing needs an S3 key pair. Rather than declare another credential shape, support declares the
|
|
@@ -28,8 +31,17 @@ const SUPPORT_R2_SECRET = "support-r2-credentials";
|
|
|
28
31
|
* reads the decline the bindings side already reports.
|
|
29
32
|
*/
|
|
30
33
|
const SUPPORT_BUCKET_BINDING = "SUPPORT_BUCKET";
|
|
34
|
+
/**
|
|
35
|
+
* The presence ticket key (#816), declared exactly as core states it. The name is the join key, so every
|
|
36
|
+
* capability in a Worker that declares rooms signs and verifies with the one key, and nobody chooses its
|
|
37
|
+
* value: `pithy add` mints the local one and `pithy secrets provision` the deployed ones.
|
|
38
|
+
*/
|
|
39
|
+
const supportPresenceRegistry = defineSecretRegistry({ [PRESENCE_TICKET_KEY_SECRET]: PRESENCE_TICKET_KEY_ENTRY });
|
|
31
40
|
/** The support capability's secret-registry slice — aggregated into the shared accessor at startup. */
|
|
32
|
-
const supportSecretsRegistry =
|
|
41
|
+
const supportSecretsRegistry = defineSecretRegistry({
|
|
42
|
+
...r2CredentialsRegistry(SUPPORT_R2_SECRET, SUPPORT_BUCKET_BINDING),
|
|
43
|
+
...supportPresenceRegistry
|
|
44
|
+
});
|
|
33
45
|
/**
|
|
34
46
|
* **The credential this composition cannot reach**, as the `inapplicableSecrets` contribution (#541).
|
|
35
47
|
*
|
|
@@ -53,4 +65,4 @@ function inapplicableAttachmentSecrets(config) {
|
|
|
53
65
|
return { [SUPPORT_R2_SECRET]: "support() stores no attachments — attachments.enabled, attachments.retainRaw and submission.attachments.enabled are all off" };
|
|
54
66
|
}
|
|
55
67
|
//#endregion
|
|
56
|
-
export { SUPPORT_BUCKET_BINDING, SUPPORT_R2_SECRET, inapplicableAttachmentSecrets, supportSecretsRegistry };
|
|
68
|
+
export { SUPPORT_BUCKET_BINDING, SUPPORT_R2_SECRET, inapplicableAttachmentSecrets, supportPresenceRegistry, supportSecretsRegistry };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import type { DocumentStore } from "@pithy-sh/vector/src/data/documents";
|
|
4
|
+
import { type VectorStore } from "@pithy-sh/vector/src/index/index";
|
|
5
|
+
import { type SupportDatabase } from "../data/tables";
|
|
6
|
+
/**
|
|
7
|
+
* Stamp `ids` with `model`, for the messages that still exist, and delete the vector of every one that does not.
|
|
8
|
+
*
|
|
9
|
+
* The stamp is an `INSERT … SELECT` from `pithy_support_messages`, so a message deleted after its vector was read
|
|
10
|
+
* gets no stamp. Its vector was upserted after the purge deleted it, so it is deleted here, by id: nothing else
|
|
11
|
+
* names it once the message row is gone. Returns the ids that were stamped.
|
|
12
|
+
*/
|
|
13
|
+
export declare function writeStamps(db: SupportDatabase, store: VectorStore | undefined, ids: readonly string[], model: string, at: Date): Promise<Set<string>>;
|
|
14
|
+
/**
|
|
15
|
+
* The message corpus as a `DocumentStore`. `store` is the index a re-embed writes to, so a stamp that finds its
|
|
16
|
+
* message gone deletes the vector the re-embed just wrote for it.
|
|
17
|
+
*/
|
|
18
|
+
export declare function supportMessageDocuments(db: SupportDatabase, options?: {
|
|
19
|
+
store?: VectorStore;
|
|
20
|
+
}): DocumentStore;
|
|
21
|
+
//# sourceMappingURL=documents.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"documents.d.ts","sourceRoot":"","sources":["../../src/semantic/documents.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qCAAqC,CAAC;AACzE,OAAO,EAAiB,KAAK,WAAW,EAAE,MAAM,kCAAkC,CAAC;AAEnF,OAAO,EAAoD,KAAK,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAmCxG;;;;;;GAMG;AACH,wBAAsB,WAAW,CAC/B,EAAE,EAAE,eAAe,EACnB,KAAK,EAAE,WAAW,GAAG,SAAS,EAC9B,GAAG,EAAE,SAAS,MAAM,EAAE,EACtB,KAAK,EAAE,MAAM,EACb,EAAE,EAAE,IAAI,GACP,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CA4BtB;AAgBD;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,EAAE,EAAE,eAAe,EAAE,OAAO,GAAE;IAAE,KAAK,CAAC,EAAE,WAAW,CAAA;CAAO,GAAG,aAAa,CAwEjH"}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { SupportEmbedding } from "../data/embedding.js";
|
|
4
|
+
import { SUPPORT_EMBEDDINGS_TABLE, SUPPORT_MESSAGES_TABLE } from "../data/tables.js";
|
|
5
|
+
import { SUPPORT_MESSAGES_INDEX, embeddingText } from "./messageIndex.js";
|
|
6
|
+
import { InternalError } from "@pithy-sh/core/src/error/pithyError";
|
|
7
|
+
import { chunkByBoundParameters } from "@pithy-sh/core/src/data/boundParameters";
|
|
8
|
+
import { deleteVectors } from "@pithy-sh/vector/src/index/index";
|
|
9
|
+
//#region src/semantic/documents.ts
|
|
10
|
+
/**
|
|
11
|
+
* `@pithy-sh/vector`'s `DocumentStore` seam over `pithy_support_messages` — so the semantic index is
|
|
12
|
+
* re-embedded by `vector`'s own reprocess orchestration rather than a second one.
|
|
13
|
+
*
|
|
14
|
+
* **The text stays in support's table.** A document is a message read through the join: its content is the text
|
|
15
|
+
* {@link embeddingText} embeds, and its `model` is the stamp in `pithy_support_embeddings`, null for a message
|
|
16
|
+
* never embedded. `markEmbedded` writes stamps. Nothing here copies a message into `pithy_vector_documents`.
|
|
17
|
+
*
|
|
18
|
+
* **Inbound messages only**, because only they are embedded on arrival: an operator's own reply says nothing
|
|
19
|
+
* about what the customer asked, and embedding it would put the adopter's words beside the customer's.
|
|
20
|
+
*
|
|
21
|
+
* **Metadata is empty, always.** Reprocess upserts a document's metadata onto its vector, so `{}` here is what
|
|
22
|
+
* keeps a re-embed to ids only, the same as the write in `embed.ts`.
|
|
23
|
+
*
|
|
24
|
+
* One index: every call names `support-messages` or reads nothing.
|
|
25
|
+
*/
|
|
26
|
+
/** A stamp insert binds the model and the time before the id list. */
|
|
27
|
+
const STAMP_FIXED_PARAMETERS = 2;
|
|
28
|
+
/** Message reads bind `direction` before the id list. */
|
|
29
|
+
const READ_FIXED_PARAMETERS = 1;
|
|
30
|
+
/**
|
|
31
|
+
* Stamp `ids` with `model`, for the messages that still exist, and delete the vector of every one that does not.
|
|
32
|
+
*
|
|
33
|
+
* The stamp is an `INSERT … SELECT` from `pithy_support_messages`, so a message deleted after its vector was read
|
|
34
|
+
* gets no stamp. Its vector was upserted after the purge deleted it, so it is deleted here, by id: nothing else
|
|
35
|
+
* names it once the message row is gone. Returns the ids that were stamped.
|
|
36
|
+
*/
|
|
37
|
+
async function writeStamps(db, store, ids, model, at) {
|
|
38
|
+
const stamped = /* @__PURE__ */ new Set();
|
|
39
|
+
if (ids.length === 0) return stamped;
|
|
40
|
+
const embeddedAt = SupportEmbedding.shape.embeddedAt.encode(at);
|
|
41
|
+
for (const group of chunkByBoundParameters(ids, STAMP_FIXED_PARAMETERS)) {
|
|
42
|
+
await db.insertInto(SUPPORT_EMBEDDINGS_TABLE).columns([
|
|
43
|
+
"messageId",
|
|
44
|
+
"model",
|
|
45
|
+
"embeddedAt"
|
|
46
|
+
]).expression((eb) => eb.selectFrom(SUPPORT_MESSAGES_TABLE).select((inner) => [
|
|
47
|
+
"id",
|
|
48
|
+
inner.val(model).as("model"),
|
|
49
|
+
inner.val(embeddedAt).as("embeddedAt")
|
|
50
|
+
]).where("id", "in", group)).onConflict((conflict) => conflict.column("messageId").doUpdateSet((eb) => ({
|
|
51
|
+
model: eb.ref("excluded.model"),
|
|
52
|
+
embeddedAt: eb.ref("excluded.embeddedAt")
|
|
53
|
+
}))).execute();
|
|
54
|
+
const present = await db.selectFrom(SUPPORT_MESSAGES_TABLE).select("id").where("id", "in", group).execute();
|
|
55
|
+
for (const row of present) stamped.add(row.id);
|
|
56
|
+
}
|
|
57
|
+
const gone = ids.filter((id) => !stamped.has(id));
|
|
58
|
+
if (gone.length > 0 && store) await deleteVectors(store, gone);
|
|
59
|
+
return stamped;
|
|
60
|
+
}
|
|
61
|
+
function documentOf(row) {
|
|
62
|
+
const receivedAt = new Date(Number(row.receivedAt));
|
|
63
|
+
return {
|
|
64
|
+
id: row.id,
|
|
65
|
+
indexName: SUPPORT_MESSAGES_INDEX,
|
|
66
|
+
namespace: null,
|
|
67
|
+
content: embeddingText(row.subject, row.textBody),
|
|
68
|
+
metadata: {},
|
|
69
|
+
model: row.model,
|
|
70
|
+
createdAt: receivedAt,
|
|
71
|
+
updatedAt: row.embeddedAt === null ? receivedAt : new Date(Number(row.embeddedAt))
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The message corpus as a `DocumentStore`. `store` is the index a re-embed writes to, so a stamp that finds its
|
|
76
|
+
* message gone deletes the vector the re-embed just wrote for it.
|
|
77
|
+
*/
|
|
78
|
+
function supportMessageDocuments(db, options = {}) {
|
|
79
|
+
const base = () => db.selectFrom(SUPPORT_MESSAGES_TABLE).leftJoin(SUPPORT_EMBEDDINGS_TABLE, `${SUPPORT_EMBEDDINGS_TABLE}.messageId`, `${SUPPORT_MESSAGES_TABLE}.id`).select([
|
|
80
|
+
`${SUPPORT_MESSAGES_TABLE}.id as id`,
|
|
81
|
+
`${SUPPORT_MESSAGES_TABLE}.subject as subject`,
|
|
82
|
+
`${SUPPORT_MESSAGES_TABLE}.textBody as textBody`,
|
|
83
|
+
`${SUPPORT_MESSAGES_TABLE}.receivedAt as receivedAt`,
|
|
84
|
+
`${SUPPORT_EMBEDDINGS_TABLE}.model as model`,
|
|
85
|
+
`${SUPPORT_EMBEDDINGS_TABLE}.embeddedAt as embeddedAt`
|
|
86
|
+
]).where(`${SUPPORT_MESSAGES_TABLE}.direction`, "=", "inbound");
|
|
87
|
+
const ours = (indexName) => indexName === SUPPORT_MESSAGES_INDEX;
|
|
88
|
+
return {
|
|
89
|
+
async put() {
|
|
90
|
+
throw new InternalError({
|
|
91
|
+
message: "Support messages cannot be written through the vector document store.",
|
|
92
|
+
detail: "supportMessageDocuments.put: the corpus is pithy_support_messages, written by support alone"
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
async get(indexName, id) {
|
|
96
|
+
if (!ours(indexName)) return null;
|
|
97
|
+
const row = await base().where(`${SUPPORT_MESSAGES_TABLE}.id`, "=", id).executeTakeFirst();
|
|
98
|
+
return row ? documentOf(row) : null;
|
|
99
|
+
},
|
|
100
|
+
async byIds(indexName, ids) {
|
|
101
|
+
if (!ours(indexName) || ids.length === 0) return [];
|
|
102
|
+
const documents = [];
|
|
103
|
+
for (const group of chunkByBoundParameters(ids, READ_FIXED_PARAMETERS)) {
|
|
104
|
+
const rows = await base().where(`${SUPPORT_MESSAGES_TABLE}.id`, "in", group).execute();
|
|
105
|
+
for (const row of rows) documents.push(documentOf(row));
|
|
106
|
+
}
|
|
107
|
+
return documents;
|
|
108
|
+
},
|
|
109
|
+
async remove(indexName, id) {
|
|
110
|
+
if (!ours(indexName)) return false;
|
|
111
|
+
return ((await db.deleteFrom("pithySupportEmbeddings").where("messageId", "=", id).executeTakeFirst()).numDeletedRows ?? 0n) > 0n;
|
|
112
|
+
},
|
|
113
|
+
async page(request) {
|
|
114
|
+
if (!ours(request.indexName)) return [];
|
|
115
|
+
let query = base().orderBy(`${SUPPORT_MESSAGES_TABLE}.id`, "asc").limit(request.limit);
|
|
116
|
+
if (request.after !== null) query = query.where(`${SUPPORT_MESSAGES_TABLE}.id`, ">", request.after);
|
|
117
|
+
const stale = request.staleModel;
|
|
118
|
+
if (stale !== void 0) query = query.where((eb) => eb.or([eb(`${SUPPORT_EMBEDDINGS_TABLE}.model`, "is", null), eb(`${SUPPORT_EMBEDDINGS_TABLE}.model`, "!=", stale)]));
|
|
119
|
+
return (await query.execute()).map((row) => documentOf(row));
|
|
120
|
+
},
|
|
121
|
+
async markEmbedded(indexName, ids, model, at) {
|
|
122
|
+
if (!ours(indexName)) return;
|
|
123
|
+
await writeStamps(db, options.store, ids, model, at);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
//#endregion
|
|
128
|
+
export { supportMessageDocuments, writeStamps };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import type { Logger } from "@pithy-sh/core/src/logger/logger";
|
|
4
|
+
import type { VectorIndexConfig } from "@pithy-sh/vector/src/config/config";
|
|
5
|
+
import { type VectorAi } from "@pithy-sh/vector/src/embed/embed";
|
|
6
|
+
import { type VectorStore } from "@pithy-sh/vector/src/index/index";
|
|
7
|
+
import { type SupportDatabase } from "../data/tables";
|
|
8
|
+
/**
|
|
9
|
+
* Embed one stored message into the semantic index — the step the classification Workflow runs after it
|
|
10
|
+
* classifies.
|
|
11
|
+
*
|
|
12
|
+
* **The vector is written, then the stamp.** A step that dies between the two leaves a message with no stamp,
|
|
13
|
+
* which is exactly the set `pithy vector reprocess --index support-messages` picks up, so a half-done write heals
|
|
14
|
+
* rather than hides.
|
|
15
|
+
*
|
|
16
|
+
* **A message gone by the stamp keeps no vector.** The stamp is written only while the message exists, and a
|
|
17
|
+
* message deleted between the read and the upsert has its vector deleted again, so a purge that lands mid-step
|
|
18
|
+
* leaves nothing derived from the person's words.
|
|
19
|
+
*
|
|
20
|
+
* **Ids only.** The upsert carries the message id and the components, and no metadata at all.
|
|
21
|
+
*/
|
|
22
|
+
/** What embedding a message needs. Every one is a seam, so a test hands in fakes. */
|
|
23
|
+
export interface EmbedMessageDeps {
|
|
24
|
+
/** The support tables. */
|
|
25
|
+
db: SupportDatabase;
|
|
26
|
+
/** The Workers AI binding. */
|
|
27
|
+
ai: VectorAi;
|
|
28
|
+
/** The Vectorize binding the message index arrives on. */
|
|
29
|
+
store: VectorStore;
|
|
30
|
+
/** The index's config — the model it is pinned to and the width it holds. */
|
|
31
|
+
index: VectorIndexConfig;
|
|
32
|
+
/** Now. */
|
|
33
|
+
now: () => Date;
|
|
34
|
+
}
|
|
35
|
+
/** Embed one message and stamp it. Returns false when the message no longer exists, read or stamp. */
|
|
36
|
+
export declare function embedMessage(deps: EmbedMessageDeps, messageId: string): Promise<boolean>;
|
|
37
|
+
/** What one embedding step did. Returned through the step, so a replay restores it. */
|
|
38
|
+
export type EmbedOutcome = {
|
|
39
|
+
embedded: true;
|
|
40
|
+
} | {
|
|
41
|
+
embedded: false;
|
|
42
|
+
/**
|
|
43
|
+
* `gone` — the message no longer exists. `unbound` — the index's binding is not on this worker yet, until
|
|
44
|
+
* `pithy support provision` deploys it. `failed` — the model or the index did not answer.
|
|
45
|
+
*/
|
|
46
|
+
reason: "gone" | "unbound" | "failed";
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* The embedding step, as the classification Workflow runs it: **never fatal**.
|
|
50
|
+
*
|
|
51
|
+
* A message that could not be embedded is stored, classified, and missing from semantic results only — and,
|
|
52
|
+
* having no stamp, it is exactly what the next `pithy vector reprocess --index support-messages` picks up. So a
|
|
53
|
+
* failure is reported in the outcome and the log, and never thrown into the Workflow that also classifies.
|
|
54
|
+
*/
|
|
55
|
+
export declare function runEmbedding(deps: Omit<EmbedMessageDeps, "store"> & {
|
|
56
|
+
store: VectorStore | undefined;
|
|
57
|
+
log?: Logger;
|
|
58
|
+
}, messageId: string): Promise<EmbedOutcome>;
|
|
59
|
+
//# sourceMappingURL=embed.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"embed.d.ts","sourceRoot":"","sources":["../../src/semantic/embed.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kCAAkC,CAAC;AAC/D,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oCAAoC,CAAC;AAC5E,OAAO,EAAiB,KAAK,QAAQ,EAAE,MAAM,kCAAkC,CAAC;AAChF,OAAO,EAAiB,KAAK,WAAW,EAAE,MAAM,kCAAkC,CAAC;AACnF,OAAO,EAA0B,KAAK,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAI9E;;;;;;;;;;;;;GAaG;AAEH,qFAAqF;AACrF,MAAM,WAAW,gBAAgB;IAC/B,0BAA0B;IAC1B,EAAE,EAAE,eAAe,CAAC;IACpB,8BAA8B;IAC9B,EAAE,EAAE,QAAQ,CAAC;IACb,0DAA0D;IAC1D,KAAK,EAAE,WAAW,CAAC;IACnB,6EAA6E;IAC7E,KAAK,EAAE,iBAAiB,CAAC;IACzB,WAAW;IACX,GAAG,EAAE,MAAM,IAAI,CAAC;CACjB;AAED,sGAAsG;AACtG,wBAAsB,YAAY,CAAC,IAAI,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAe9F;AAED,uFAAuF;AACvF,MAAM,MAAM,YAAY,GACpB;IAAE,QAAQ,EAAE,IAAI,CAAA;CAAE,GAClB;IACE,QAAQ,EAAE,KAAK,CAAC;IAChB;;;OAGG;IACH,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,QAAQ,CAAC;CACvC,CAAC;AAEN;;;;;;GAMG;AACH,wBAAsB,YAAY,CAChC,IAAI,EAAE,IAAI,CAAC,gBAAgB,EAAE,OAAO,CAAC,GAAG;IAAE,KAAK,EAAE,WAAW,GAAG,SAAS,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,EACxF,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,YAAY,CAAC,CAkBvB"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { SUPPORT_MESSAGES_TABLE } from "../data/tables.js";
|
|
4
|
+
import { embeddingText } from "./messageIndex.js";
|
|
5
|
+
import { writeStamps } from "./documents.js";
|
|
6
|
+
import { embedForIndex } from "@pithy-sh/vector/src/embed/embed";
|
|
7
|
+
import { upsertVectors } from "@pithy-sh/vector/src/index/index";
|
|
8
|
+
//#region src/semantic/embed.ts
|
|
9
|
+
/** Embed one message and stamp it. Returns false when the message no longer exists, read or stamp. */
|
|
10
|
+
async function embedMessage(deps, messageId) {
|
|
11
|
+
const message = await deps.db.selectFrom(SUPPORT_MESSAGES_TABLE).select([
|
|
12
|
+
"id",
|
|
13
|
+
"subject",
|
|
14
|
+
"textBody"
|
|
15
|
+
]).where("id", "=", messageId).executeTakeFirst();
|
|
16
|
+
if (!message) return false;
|
|
17
|
+
const [values] = await embedForIndex(deps.ai, deps.index, [embeddingText(message.subject, message.textBody)]);
|
|
18
|
+
await upsertVectors(deps.store, deps.index, [{
|
|
19
|
+
id: message.id,
|
|
20
|
+
values
|
|
21
|
+
}]);
|
|
22
|
+
return (await writeStamps(deps.db, deps.store, [message.id], deps.index.model, deps.now())).has(message.id);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The embedding step, as the classification Workflow runs it: **never fatal**.
|
|
26
|
+
*
|
|
27
|
+
* A message that could not be embedded is stored, classified, and missing from semantic results only — and,
|
|
28
|
+
* having no stamp, it is exactly what the next `pithy vector reprocess --index support-messages` picks up. So a
|
|
29
|
+
* failure is reported in the outcome and the log, and never thrown into the Workflow that also classifies.
|
|
30
|
+
*/
|
|
31
|
+
async function runEmbedding(deps, messageId) {
|
|
32
|
+
if (!deps.store) {
|
|
33
|
+
deps.log?.warn("support semantic search is on but the index is not bound to this worker — not embedding", { messageId });
|
|
34
|
+
return {
|
|
35
|
+
embedded: false,
|
|
36
|
+
reason: "unbound"
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
return await embedMessage({
|
|
41
|
+
...deps,
|
|
42
|
+
store: deps.store
|
|
43
|
+
}, messageId) ? { embedded: true } : {
|
|
44
|
+
embedded: false,
|
|
45
|
+
reason: "gone"
|
|
46
|
+
};
|
|
47
|
+
} catch (error) {
|
|
48
|
+
deps.log?.warn("support message not embedded — it stays out of semantic results until a reprocess", {
|
|
49
|
+
messageId,
|
|
50
|
+
error
|
|
51
|
+
});
|
|
52
|
+
return {
|
|
53
|
+
embedded: false,
|
|
54
|
+
reason: "failed"
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
export { embedMessage, runEmbedding };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { type FeatureIdentity } from "@pithy-sh/core/src/naming/feature";
|
|
4
|
+
import type { VectorIndexConfig } from "@pithy-sh/vector/src/config/config";
|
|
5
|
+
import type { VectorIndexShape } from "@pithy-sh/vector/src/provision/provisionVector";
|
|
6
|
+
import { type SupportSearchConfig } from "../config/config";
|
|
7
|
+
/**
|
|
8
|
+
* The semantic message index — what it is called, what shape it has, and what text goes into it.
|
|
9
|
+
*
|
|
10
|
+
* **Support owns it, through `@pithy-sh/vector`'s public modules, and the adopter composes no `vector()`.** The
|
|
11
|
+
* index is declared here as a {@link VectorIndexConfig}, the same value `vector()` builds from an adopter's
|
|
12
|
+
* `indexes` block, so `embedForIndex`, `queryVectors` and the reprocess orchestration take it unchanged and pin
|
|
13
|
+
* the model on writes and queries alike.
|
|
14
|
+
*
|
|
15
|
+
* **Vectors carry ids and nothing else.** A vector's id is the message's id, and no subject, body or address
|
|
16
|
+
* reaches Vectorize metadata: the text stays in D1 under the privacy markers its schema carries. A match is
|
|
17
|
+
* resolved back to its thread in D1, which is also where every filter applies.
|
|
18
|
+
*/
|
|
19
|
+
/** The index's name in `pithy vector reprocess --index`, and the `<thing>` its Cloudflare name ends with. */
|
|
20
|
+
export declare const SUPPORT_MESSAGES_INDEX = "support-messages";
|
|
21
|
+
/** The Vectorize binding the index arrives on, in the app Worker and the classification worker alike. */
|
|
22
|
+
export declare const SUPPORT_VECTORIZE_BINDING = "SUPPORT_VECTORIZE";
|
|
23
|
+
/** The Workers AI binding a query and an inbound message are embedded on. */
|
|
24
|
+
export declare const SUPPORT_AI_BINDING = "AI";
|
|
25
|
+
/**
|
|
26
|
+
* How much of a message is embedded. A support message states its point early, and the rest is usually a
|
|
27
|
+
* quoted thread and a signature — text that would pull every vector toward the same neighborhood.
|
|
28
|
+
*/
|
|
29
|
+
export declare const MAX_EMBED_CHARS = 4000;
|
|
30
|
+
/** The index this search config asks for, or `undefined` when `search.semantic` is off. */
|
|
31
|
+
export declare function supportMessageIndex(search: SupportSearchConfig): VectorIndexConfig | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* The shape `pithy support provision` creates the index with — `vector`'s own provisioning shape, so a support
|
|
34
|
+
* index and a `vector()` index are created, and refused on a mismatch, by the same rule. `undefined` when off.
|
|
35
|
+
*/
|
|
36
|
+
export declare function supportMessageIndexShape(search: SupportSearchConfig): VectorIndexShape | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* The index's Cloudflare name — `<project>-<env>-support-messages`, or a feature's own.
|
|
39
|
+
*
|
|
40
|
+
* Through the naming facade, which refuses a Vectorize name over 64 bytes rather than truncating it: a shortened
|
|
41
|
+
* name is a different, empty index.
|
|
42
|
+
*/
|
|
43
|
+
export declare function supportMessageIndexName(project: string, env: string, feature?: FeatureIdentity): string;
|
|
44
|
+
/** The text one message is embedded as: its subject, then its body, bounded. */
|
|
45
|
+
export declare function embeddingText(subject: string, body: string): string;
|
|
46
|
+
//# sourceMappingURL=messageIndex.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"messageIndex.d.ts","sourceRoot":"","sources":["../../src/semantic/messageIndex.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,eAAe,EAA6B,MAAM,mCAAmC,CAAC;AAEpG,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oCAAoC,CAAC;AAC5E,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gDAAgD,CAAC;AACvF,OAAO,EAAE,KAAK,mBAAmB,EAAmB,MAAM,kBAAkB,CAAC;AAE7E;;;;;;;;;;;GAWG;AAEH,6GAA6G;AAC7G,eAAO,MAAM,sBAAsB,qBAAqB,CAAC;AAEzD,yGAAyG;AACzG,eAAO,MAAM,yBAAyB,sBAAsB,CAAC;AAE7D,6EAA6E;AAC7E,eAAO,MAAM,kBAAkB,OAAO,CAAC;AAEvC;;;GAGG;AACH,eAAO,MAAM,eAAe,OAAO,CAAC;AAEpC,2FAA2F;AAC3F,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,mBAAmB,GAAG,iBAAiB,GAAG,SAAS,CAU9F;AAED;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,mBAAmB,GAAG,gBAAgB,GAAG,SAAS,CAGlG;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,MAAM,CAGvG;AAED,gFAAgF;AAChF,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAEnE"}
|