@pithy-sh/support 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/dist/ai/classify.d.ts +90 -0
  2. package/dist/ai/classify.d.ts.map +1 -0
  3. package/dist/ai/classify.js +151 -0
  4. package/dist/attachment/store.d.ts +53 -0
  5. package/dist/attachment/store.d.ts.map +1 -0
  6. package/dist/attachment/store.js +67 -0
  7. package/dist/audit/actions.d.ts +68 -0
  8. package/dist/audit/actions.d.ts.map +1 -0
  9. package/dist/audit/actions.js +68 -0
  10. package/dist/capability.d.ts +93 -0
  11. package/dist/capability.d.ts.map +1 -0
  12. package/dist/capability.js +187 -0
  13. package/dist/client/projection.d.ts +56 -0
  14. package/dist/client/projection.d.ts.map +1 -0
  15. package/dist/client/projection.js +0 -0
  16. package/dist/config/config.d.ts +215 -0
  17. package/dist/config/config.d.ts.map +1 -0
  18. package/dist/config/config.js +169 -0
  19. package/dist/data/attachment.d.ts +30 -0
  20. package/dist/data/attachment.d.ts.map +1 -0
  21. package/dist/data/attachment.js +32 -0
  22. package/dist/data/billingScope.d.ts +30 -0
  23. package/dist/data/billingScope.d.ts.map +1 -0
  24. package/dist/data/billingScope.js +32 -0
  25. package/dist/data/categories.d.ts +54 -0
  26. package/dist/data/categories.d.ts.map +1 -0
  27. package/dist/data/categories.js +97 -0
  28. package/dist/data/classification.d.ts +34 -0
  29. package/dist/data/classification.d.ts.map +1 -0
  30. package/dist/data/classification.js +28 -0
  31. package/dist/data/enums.d.ts +82 -0
  32. package/dist/data/enums.d.ts.map +1 -0
  33. package/dist/data/enums.js +70 -0
  34. package/dist/data/flag.d.ts +25 -0
  35. package/dist/data/flag.d.ts.map +1 -0
  36. package/dist/data/flag.js +27 -0
  37. package/dist/data/message.d.ts +81 -0
  38. package/dist/data/message.d.ts.map +1 -0
  39. package/dist/data/message.js +84 -0
  40. package/dist/data/tables.d.ts +160 -0
  41. package/dist/data/tables.d.ts.map +1 -0
  42. package/dist/data/tables.js +44 -0
  43. package/dist/data/thread.d.ts +77 -0
  44. package/dist/data/thread.d.ts.map +1 -0
  45. package/dist/data/thread.js +65 -0
  46. package/dist/error/errors.d.ts +64 -0
  47. package/dist/error/errors.d.ts.map +1 -0
  48. package/dist/error/errors.js +82 -0
  49. package/dist/http/guards.d.ts +44 -0
  50. package/dist/http/guards.d.ts.map +1 -0
  51. package/dist/http/guards.js +53 -0
  52. package/dist/http/handlers.d.ts +89 -0
  53. package/dist/http/handlers.d.ts.map +1 -0
  54. package/dist/http/handlers.js +248 -0
  55. package/dist/http/resolve.d.ts +44 -0
  56. package/dist/http/resolve.d.ts.map +1 -0
  57. package/dist/http/resolve.js +72 -0
  58. package/dist/http/responses.d.ts +829 -0
  59. package/dist/http/responses.d.ts.map +1 -0
  60. package/dist/http/responses.js +303 -0
  61. package/dist/http/routes.d.ts +109 -0
  62. package/dist/http/routes.d.ts.map +1 -0
  63. package/dist/http/routes.js +62 -0
  64. package/dist/http/schemas.d.ts +121 -0
  65. package/dist/http/schemas.d.ts.map +1 -0
  66. package/dist/http/schemas.js +89 -0
  67. package/dist/http/scopes.d.ts +56 -0
  68. package/dist/http/scopes.d.ts.map +1 -0
  69. package/dist/http/scopes.js +107 -0
  70. package/dist/http/views.d.ts +36 -0
  71. package/dist/http/views.d.ts.map +1 -0
  72. package/dist/http/views.js +144 -0
  73. package/dist/inbound/authenticity.d.ts +81 -0
  74. package/dist/inbound/authenticity.d.ts.map +1 -0
  75. package/dist/inbound/authenticity.js +61 -0
  76. package/dist/inbound/guard.d.ts +74 -0
  77. package/dist/inbound/guard.d.ts.map +1 -0
  78. package/dist/inbound/guard.js +78 -0
  79. package/dist/inbound/handler.d.ts +28 -0
  80. package/dist/inbound/handler.d.ts.map +1 -0
  81. package/dist/inbound/handler.js +82 -0
  82. package/dist/inbound/ingest.d.ts +104 -0
  83. package/dist/inbound/ingest.d.ts.map +1 -0
  84. package/dist/inbound/ingest.js +326 -0
  85. package/dist/inbound/recipient.d.ts +16 -0
  86. package/dist/inbound/recipient.d.ts.map +1 -0
  87. package/dist/inbound/recipient.js +46 -0
  88. package/dist/index.d.ts +20 -0
  89. package/dist/index.d.ts.map +1 -0
  90. package/dist/index.js +14 -0
  91. package/dist/link/sender.d.ts +160 -0
  92. package/dist/link/sender.d.ts.map +1 -0
  93. package/dist/link/sender.js +200 -0
  94. package/dist/migrations/0001_threads.d.ts +26 -0
  95. package/dist/migrations/0001_threads.d.ts.map +1 -0
  96. package/dist/migrations/0001_threads.js +103 -0
  97. package/dist/mime/address.d.ts +22 -0
  98. package/dist/mime/address.d.ts.map +1 -0
  99. package/dist/mime/address.js +28 -0
  100. package/dist/mime/parse.d.ts +96 -0
  101. package/dist/mime/parse.d.ts.map +1 -0
  102. package/dist/mime/parse.js +146 -0
  103. package/dist/mime/sanitize.d.ts +25 -0
  104. package/dist/mime/sanitize.d.ts.map +1 -0
  105. package/dist/mime/sanitize.js +212 -0
  106. package/dist/mime/threading.d.ts +79 -0
  107. package/dist/mime/threading.d.ts.map +1 -0
  108. package/dist/mime/threading.js +111 -0
  109. package/dist/mime/truncate.d.ts +17 -0
  110. package/dist/mime/truncate.d.ts.map +1 -0
  111. package/dist/mime/truncate.js +47 -0
  112. package/dist/provision/provisionSupport.d.ts +158 -0
  113. package/dist/provision/provisionSupport.d.ts.map +1 -0
  114. package/dist/provision/provisionSupport.js +110 -0
  115. package/dist/provision/resolveSupportConfig.d.ts +45 -0
  116. package/dist/provision/resolveSupportConfig.d.ts.map +1 -0
  117. package/dist/provision/resolveSupportConfig.js +22 -0
  118. package/dist/reply/send.d.ts +123 -0
  119. package/dist/reply/send.d.ts.map +1 -0
  120. package/dist/reply/send.js +153 -0
  121. package/dist/reply/snippets.d.ts +79 -0
  122. package/dist/reply/snippets.d.ts.map +1 -0
  123. package/dist/reply/snippets.js +135 -0
  124. package/dist/secret/registry.d.ts +18 -0
  125. package/dist/secret/registry.d.ts.map +1 -0
  126. package/dist/secret/registry.js +21 -0
  127. package/dist/seeds/example.d.ts +3 -0
  128. package/dist/seeds/example.d.ts.map +1 -0
  129. package/dist/seeds/example.js +350 -0
  130. package/dist/store/paging.d.ts +18 -0
  131. package/dist/store/paging.d.ts.map +1 -0
  132. package/dist/store/paging.js +20 -0
  133. package/dist/store/search.d.ts +67 -0
  134. package/dist/store/search.d.ts.map +1 -0
  135. package/dist/store/search.js +160 -0
  136. package/dist/store/searchIndex.d.ts +53 -0
  137. package/dist/store/searchIndex.d.ts.map +1 -0
  138. package/dist/store/searchIndex.js +66 -0
  139. package/dist/store/threads.d.ts +147 -0
  140. package/dist/store/threads.d.ts.map +1 -0
  141. package/dist/store/threads.js +197 -0
  142. package/dist/submission/encoding.d.ts +21 -0
  143. package/dist/submission/encoding.d.ts.map +1 -0
  144. package/dist/submission/encoding.js +55 -0
  145. package/dist/submission/guard.d.ts +71 -0
  146. package/dist/submission/guard.d.ts.map +1 -0
  147. package/dist/submission/guard.js +46 -0
  148. package/dist/submission/submit.d.ts +152 -0
  149. package/dist/submission/submit.d.ts.map +1 -0
  150. package/dist/submission/submit.js +319 -0
  151. package/dist/version.generated.d.ts +5 -0
  152. package/dist/version.generated.d.ts.map +1 -0
  153. package/dist/version.generated.js +7 -0
  154. package/dist/workflows/classify.d.ts +63 -0
  155. package/dist/workflows/classify.d.ts.map +1 -0
  156. package/dist/workflows/classify.js +68 -0
  157. package/dist/workflows/retryPolicy.d.ts +39 -0
  158. package/dist/workflows/retryPolicy.d.ts.map +1 -0
  159. package/dist/workflows/retryPolicy.js +43 -0
  160. package/dist/workflows/specs.d.ts +43 -0
  161. package/dist/workflows/specs.d.ts.map +1 -0
  162. package/dist/workflows/specs.js +46 -0
  163. package/dist/workflows/worker.d.ts +48 -0
  164. package/dist/workflows/worker.d.ts.map +1 -0
  165. package/dist/workflows/worker.js +37 -0
  166. package/package.json +24 -15
  167. package/src/version.generated.ts +1 -1
@@ -0,0 +1,135 @@
1
+ import { SupportInvalidCategoryError } from "../error/errors.js";
2
+ import { z } from "zod";
3
+ //#region src/reply/snippets.ts
4
+ /**
5
+ * Canned replies — starting points a human picks in the dashboard, edits, and sends.
6
+ *
7
+ * ## What these are, and what they are not
8
+ *
9
+ * A snippet is **body text**, not an email template. The rendering shell — the adopter's theme, the
10
+ * HTML and plain-text pair, the threading headers — is the single `supportReply` template in
11
+ * `@pithy-sh/email`. Adding a second email template per canned reply would put the adopter's
12
+ * wording in a precompiled Handlebars file that only a release can change, which is exactly backwards
13
+ * for text somebody wants to reword on a Tuesday.
14
+ *
15
+ * So the Worker serves a catalog and **never renders it**. The dashboard shows the list, the operator
16
+ * picks one, edits it, and posts the finished body to the reply route — which is why the placeholder
17
+ * tokens below are substituted client-side and this package owns no template engine. A support reply
18
+ * is a letter a person sent; the machine's only job is to hand them a better blank page.
19
+ *
20
+ * ## Federated, like the taxonomy
21
+ *
22
+ * Pithy ships a small set keyed to the default categories, and an adopter adds their own with
23
+ * {@link defineSupportReplies} — the same shape `defineSupportCategories` uses, for the same reason.
24
+ * A snippet tagged with a category is offered first on a thread the classifier put in that category,
25
+ * which is the one place the classification does work for a human rather than for a filter.
26
+ *
27
+ * The shipped wording is deliberately plain and slightly incomplete: every one of them has a blank
28
+ * an operator has to fill. A canned reply that could be sent without reading it is how a support
29
+ * inbox starts insulting people.
30
+ */
31
+ /** The shape a snippet key must take: lowercase `snake_case`, so it is a stable id in a dashboard. */
32
+ const SNIPPET_KEY = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/;
33
+ /** How long a snippet body may be. Long enough for a real answer; bounded because it ships in a manifest. */
34
+ const MAX_BODY = 2e3;
35
+ /**
36
+ * The tokens a dashboard substitutes before sending. Documented here because they are a contract
37
+ * between this catalog and whatever renders it, and deliberately few: a placeholder language is a
38
+ * template engine, and a template engine over untrusted context is a rendering vulnerability.
39
+ */
40
+ const SNIPPET_TOKENS = {
41
+ /** The sender's display name, or their address when the app knows no name. */
42
+ name: "{{name}}",
43
+ /** The thread's subject. */
44
+ subject: "{{subject}}"
45
+ };
46
+ /** One canned reply. */
47
+ const SupportReplySnippet = z.object({
48
+ label: z.string().min(1).max(80).describe("What the dashboard shows in the picker, e.g. `Refund issued`. A verb phrase, not a category name."),
49
+ category: z.string().optional().describe("The category this snippet is offered first for. Optional — a snippet with no category is always offered, which is right for the general-purpose ones."),
50
+ body: z.string().min(1).max(MAX_BODY).describe("The starting text, with `{{name}}` and `{{subject}}` substituted by the dashboard before sending. Written to be edited: every shipped one leaves a blank a human has to fill.")
51
+ }).describe("One canned reply — a starting point a human picks, edits, and sends.");
52
+ /**
53
+ * The set Pithy ships, keyed to the default categories.
54
+ *
55
+ * Six, not sixteen. A picker long enough to need scrolling is one nobody reads, and the ones below
56
+ * are the replies a solo developer actually writes over and over.
57
+ */
58
+ const DEFAULT_SUPPORT_REPLIES = {
59
+ refund_issued: {
60
+ label: "Refund issued",
61
+ category: "billing",
62
+ body: "Hi {{name}},\n\nI've refunded this — you should see it back on your original payment method within ___ working days.\n\nSorry for the trouble.\n"
63
+ },
64
+ billing_explained: {
65
+ label: "Explain a charge",
66
+ category: "billing",
67
+ body: "Hi {{name}},\n\nThat charge is ___. It covers ___.\n\nIf that isn't what you expected, tell me and I'll sort it out.\n"
68
+ },
69
+ sign_in_help: {
70
+ label: "Sign-in help",
71
+ category: "account_access",
72
+ body: "Hi {{name}},\n\nI've had a look — ___.\n\nTry signing in again and let me know if it still doesn't work. If the email isn't arriving, check your spam folder, and tell me which address you're using so I can confirm it matches the account.\n"
73
+ },
74
+ bug_acknowledged: {
75
+ label: "Bug acknowledged",
76
+ category: "bug_report",
77
+ body: "Hi {{name}},\n\nThanks for reporting this — I've reproduced it and it's a real bug. ___.\n\nI'll follow up here once it's fixed.\n"
78
+ },
79
+ feature_noted: {
80
+ label: "Feature request noted",
81
+ category: "feature_request",
82
+ body: "Hi {{name}},\n\nThanks — that's a good idea and I've written it down. I can't promise a date, but ___.\n\nIf you can tell me a bit about how you'd use it, that genuinely helps me prioritize.\n"
83
+ },
84
+ need_more_detail: {
85
+ label: "Ask for more detail",
86
+ body: "Hi {{name}},\n\nHappy to help with this. Could you tell me ___?\n\nA screenshot or the exact wording of any error would speed this up a lot.\n"
87
+ }
88
+ };
89
+ /**
90
+ * Declare an adopter's own canned replies, validated at author time.
91
+ *
92
+ * The same shape and the same reasoning as `defineSupportCategories`: a malformed key is a picker
93
+ * entry that silently never matches, and it should fail where the constant is written.
94
+ */
95
+ function defineSupportReplies(snippets) {
96
+ for (const [key, snippet] of Object.entries(snippets)) {
97
+ if (!SNIPPET_KEY.test(key)) throw new SupportInvalidCategoryError({
98
+ message: `Invalid support reply key: ${key}`,
99
+ action: "Use a lowercase snake_case key, e.g. `refund_issued`.",
100
+ detail: `key ${JSON.stringify(key)} does not match ${SNIPPET_KEY}`
101
+ });
102
+ const parsed = SupportReplySnippet.safeParse(snippet);
103
+ if (!parsed.success) throw new SupportInvalidCategoryError({
104
+ message: `Invalid support reply "${key}".`,
105
+ action: "Give it a label and a body, and keep the body under 2000 characters.",
106
+ detail: parsed.error.message
107
+ });
108
+ }
109
+ return snippets;
110
+ }
111
+ /** The effective catalog: the shipped set, plus the adopter's, theirs winning on a key collision. */
112
+ function resolveReplies(extra = {}) {
113
+ return {
114
+ ...DEFAULT_SUPPORT_REPLIES,
115
+ ...defineSupportReplies(extra)
116
+ };
117
+ }
118
+ /**
119
+ * The catalog as a dashboard consumes it: the snippets for this thread's category first, then the
120
+ * uncategorized general-purpose ones, then everything else.
121
+ *
122
+ * Ordering rather than filtering, deliberately. A classifier that put a refund request in
123
+ * `bug_report` would otherwise hide the refund snippets from the one person who can tell it was
124
+ * wrong — the model orders the list, it never restricts it.
125
+ */
126
+ function repliesForCategory(snippets, category) {
127
+ const entries = Object.entries(snippets).map(([key, snippet]) => ({
128
+ key,
129
+ ...snippet
130
+ }));
131
+ const rank = (snippet) => snippet.category === category ? 0 : snippet.category === void 0 ? 1 : 2;
132
+ return entries.sort((a, b) => rank(a) - rank(b) || a.key.localeCompare(b.key));
133
+ }
134
+ //#endregion
135
+ export { DEFAULT_SUPPORT_REPLIES, SNIPPET_TOKENS, SupportReplySnippet, defineSupportReplies, repliesForCategory, resolveReplies };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The secrets support reads — exactly one, and it is not support's own shape.
3
+ *
4
+ * Attachment bytes are written through the R2 binding and served as short-lived signed URLs, and
5
+ * signing needs an S3 key pair. Rather than declare another credential shape, support declares the
6
+ * *same* one `@pithy-sh/storage` owns, under its own name, through storage's factory. That is what
7
+ * lets support point the `ObjectStore` seam at `SUPPORT_BUCKET` while inheriting none of storage's
8
+ * tables, routes, or key policy — the same arrangement `@pithy-sh/media` already has.
9
+ *
10
+ * One factory rather than two hand-written declarations is also what keeps the name safe as a join
11
+ * key: `aggregateSecretRegistries` allows a name declared twice only when every axis agrees, and two
12
+ * hand-written copies drift where one factory cannot.
13
+ */
14
+ /** The name support's R2 credential bundle is stored and resolved under, per environment. */
15
+ export declare const SUPPORT_R2_SECRET = "support-r2-credentials";
16
+ /** The support capability's secret-registry slice — aggregated into the shared accessor at startup. */
17
+ export declare const supportSecretsRegistry: Record<"support-r2-credentials", import("@pithy-sh/storage/src/secret/registry").R2CredentialsEntry>;
18
+ //# sourceMappingURL=registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/secret/registry.ts"],"names":[],"mappings":"AAKA;;;;;;;;;;;;GAYG;AAEH,6FAA6F;AAC7F,eAAO,MAAM,iBAAiB,2BAA2B,CAAC;AAE1D,uGAAuG;AACvG,eAAO,MAAM,sBAAsB,sGAA2C,CAAC"}
@@ -0,0 +1,21 @@
1
+ import { r2CredentialsRegistry } from "@pithy-sh/storage/src/secret/registry";
2
+ //#region src/secret/registry.ts
3
+ /**
4
+ * The secrets support reads — exactly one, and it is not support's own shape.
5
+ *
6
+ * Attachment bytes are written through the R2 binding and served as short-lived signed URLs, and
7
+ * signing needs an S3 key pair. Rather than declare another credential shape, support declares the
8
+ * *same* one `@pithy-sh/storage` owns, under its own name, through storage's factory. That is what
9
+ * lets support point the `ObjectStore` seam at `SUPPORT_BUCKET` while inheriting none of storage's
10
+ * tables, routes, or key policy — the same arrangement `@pithy-sh/media` already has.
11
+ *
12
+ * One factory rather than two hand-written declarations is also what keeps the name safe as a join
13
+ * key: `aggregateSecretRegistries` allows a name declared twice only when every axis agrees, and two
14
+ * hand-written copies drift where one factory cannot.
15
+ */
16
+ /** The name support's R2 credential bundle is stored and resolved under, per environment. */
17
+ const SUPPORT_R2_SECRET = "support-r2-credentials";
18
+ /** The support capability's secret-registry slice — aggregated into the shared accessor at startup. */
19
+ const supportSecretsRegistry = r2CredentialsRegistry(SUPPORT_R2_SECRET);
20
+ //#endregion
21
+ export { SUPPORT_R2_SECRET, supportSecretsRegistry };
@@ -0,0 +1,3 @@
1
+ import { type SeedSet } from "@pithy-sh/core/src/seed/seed";
2
+ export declare const supportExampleSeed: SeedSet;
3
+ //# sourceMappingURL=example.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"example.d.ts","sourceRoot":"","sources":["../../src/seeds/example.ts"],"names":[],"mappings":"AAIA,OAAO,EAA2B,KAAK,OAAO,EAAE,MAAM,8BAA8B,CAAC;AAgFrF,eAAO,MAAM,kBAAkB,EAAE,OA4S/B,CAAC"}
@@ -0,0 +1,350 @@
1
+ import { SupportClassification } from "../data/classification.js";
2
+ import { SupportMessage } from "../data/message.js";
3
+ import { SupportThread } from "../data/thread.js";
4
+ import { SUPPORT_CLASSIFICATIONS_TABLE, SUPPORT_MESSAGES_TABLE, SUPPORT_THREADS_TABLE } from "../data/tables.js";
5
+ import { EXAMPLE_ADA, EXAMPLE_ALAN, EXAMPLE_GRACE } from "@pithy-sh/core/src/seed/exampleIdentities";
6
+ import { d1SeedGroup, defineSeed } from "@pithy-sh/core/src/seed/seed";
7
+ //#region src/seeds/example.ts
8
+ /**
9
+ * Four conversations for the canonical example cast, spread across the shapes a dashboard has to
10
+ * render.
11
+ *
12
+ * **Ada** is angry about a double charge — `billing`, `urgent`, and she is the one with a live Apple
13
+ * subscription in the payments seed, so a thread view opened on her shows the linkage working end to
14
+ * end rather than an empty panel. **Grace** cannot sign in — `account_access`, `normal`, two
15
+ * messages, so the thread view has an actual conversation in it and the reply path has something
16
+ * threaded to attach to. **Alan** sent a feature request that has been dealt with — `feature_request`,
17
+ * `low`, `positive`, and **archived**, so the done pile is not empty on a fresh backend and the
18
+ * archived filter is demonstrably doing something.
19
+ *
20
+ * **Grace again, from inside the app** — a `bug_report` filed through `POST /support/feedback`, so the
21
+ * fixture carries both channels and the same person on each. That pairing is the demonstration: her
22
+ * mail thread is linked to her account by an address in a header nobody proved, and her app thread is
23
+ * linked by a session, and a console that renders the two identically has a bug this seed will show
24
+ * you. It is also the only row carrying submission context, which is what a bug report knows and a
25
+ * piece of mail cannot — and the only row carrying a `declaredCategory`, because it is the only sender
26
+ * who was offered a chooser.
27
+ *
28
+ * **And it deliberately disagrees with itself.** Grace filed it under "something is missing" and the
29
+ * model read the text and called it a bug, so `declaredCategory` is `feature_request` while `category`
30
+ * is `bug_report`. That is the ordinary case rather than a contrived one — people file "X does not
31
+ * work for me" under a missing feature constantly — and it is the row that makes the rendering mistake
32
+ * visible before it ships: a console that shows one word, or shows the wrong one, has a bug this seed
33
+ * will hand you. Every mail thread's is null, because nobody asked its sender anything.
34
+ *
35
+ * That is one row per priority, all four sentiments, four categories, both channels, both link
36
+ * provenances, both a stated and an unstated category, and both sides of `archived` — the states
37
+ * anything reading these tables has to handle, without anybody authoring a fixture first.
38
+ *
39
+ * **Everything is fixed — the ids and the clock.** `pithy seed` is `INSERT OR IGNORE`, so a generated
40
+ * UUID would give Ada a second complaint on every run.
41
+ *
42
+ * **No attachments, and no raw messages.** Both would mean seeding R2 objects the D1 rows point at,
43
+ * and a fixture whose rows name bytes that are not there is worse than one that claims less. The
44
+ * classifications are seeded because they are the interesting half: a thread carries a model id, so
45
+ * the seed shows what "which rows came from which model" looks like before anybody has run one.
46
+ *
47
+ * Composed in only when the project turns on `seed.includeExamples`, and only for `dev` and
48
+ * `staging` — an example fixture never targets prod, whatever that setting says.
49
+ */
50
+ /** Where this set sorts. After auth's users (100) and payments' purchases (250), which it links to. */
51
+ const SUPPORT_EXAMPLE_SEED_ORDER = 260;
52
+ /** Fixed ids, so the fixture is idempotent and a classification cannot drift off its thread. */
53
+ const BILLING_THREAD_ID = "a1b2c3d4-0001-4a11-9c01-0e1f2a3b4c50";
54
+ const ACCESS_THREAD_ID = "a1b2c3d4-0002-4a11-9c01-0e1f2a3b4c51";
55
+ const FEATURE_THREAD_ID = "a1b2c3d4-0003-4a11-9c01-0e1f2a3b4c52";
56
+ const APP_THREAD_ID = "a1b2c3d4-0004-4a11-9c01-0e1f2a3b4c53";
57
+ const BILLING_MESSAGE_ID = "b1b2c3d4-0001-4a11-9c01-0e1f2a3b4c60";
58
+ const ACCESS_MESSAGE_ID = "b1b2c3d4-0002-4a11-9c01-0e1f2a3b4c61";
59
+ const ACCESS_REPLY_ID = "b1b2c3d4-0003-4a11-9c01-0e1f2a3b4c62";
60
+ const FEATURE_MESSAGE_ID = "b1b2c3d4-0004-4a11-9c01-0e1f2a3b4c63";
61
+ const APP_MESSAGE_ID = "b1b2c3d4-0005-4a11-9c01-0e1f2a3b4c64";
62
+ const BILLING_CLASSIFICATION_ID = "c1b2c3d4-0001-4a11-9c01-0e1f2a3b4c70";
63
+ const ACCESS_CLASSIFICATION_ID = "c1b2c3d4-0002-4a11-9c01-0e1f2a3b4c71";
64
+ const FEATURE_CLASSIFICATION_ID = "c1b2c3d4-0003-4a11-9c01-0e1f2a3b4c72";
65
+ const APP_CLASSIFICATION_ID = "c1b2c3d4-0004-4a11-9c01-0e1f2a3b4c73";
66
+ /** The inbox these threads arrived on — a subdomain, which is the only safe shape for a real one. */
67
+ const INBOX = "support@help.example.com";
68
+ /** The model the seeded classifications claim to have come from. */
69
+ const MODEL = "@cf/meta/llama-3.1-8b-instruct";
70
+ /** One fixed moment. The demo is about state, not about a calendar. */
71
+ const AT = /* @__PURE__ */ new Date("2026-01-10T09:00:00.000Z");
72
+ const LATER = /* @__PURE__ */ new Date("2026-01-10T11:30:00.000Z");
73
+ const ARCHIVED_AT = /* @__PURE__ */ new Date("2026-01-11T16:00:00.000Z");
74
+ const supportExampleSeed = defineSeed({
75
+ name: "example",
76
+ order: SUPPORT_EXAMPLE_SEED_ORDER,
77
+ environments: ["dev", "staging"],
78
+ example: true,
79
+ d1: [
80
+ d1SeedGroup("app", SUPPORT_THREADS_TABLE, SupportThread, [
81
+ {
82
+ id: BILLING_THREAD_ID,
83
+ channel: "email",
84
+ inboxAddress: INBOX,
85
+ subject: "I was charged twice this month",
86
+ fromAddress: EXAMPLE_ADA.email,
87
+ fromName: EXAMPLE_ADA.name,
88
+ senderAuthenticated: true,
89
+ userId: EXAMPLE_ADA.id,
90
+ accountLinkSource: "email_address",
91
+ category: "billing",
92
+ priority: "urgent",
93
+ sentiment: "angry",
94
+ confidence: .94,
95
+ model: MODEL,
96
+ classifiedAt: AT,
97
+ archived: false,
98
+ archivedAt: null,
99
+ archivedBy: null,
100
+ messageCount: 1,
101
+ firstMessageAt: AT,
102
+ lastMessageAt: AT,
103
+ createdAt: AT,
104
+ updatedAt: AT
105
+ },
106
+ {
107
+ id: ACCESS_THREAD_ID,
108
+ channel: "email",
109
+ inboxAddress: INBOX,
110
+ subject: "Magic link never arrives",
111
+ fromAddress: EXAMPLE_GRACE.email,
112
+ fromName: EXAMPLE_GRACE.name,
113
+ senderAuthenticated: true,
114
+ userId: EXAMPLE_GRACE.id,
115
+ accountLinkSource: "email_address",
116
+ category: "account_access",
117
+ priority: "normal",
118
+ sentiment: "frustrated",
119
+ confidence: .81,
120
+ model: MODEL,
121
+ classifiedAt: AT,
122
+ archived: false,
123
+ archivedAt: null,
124
+ archivedBy: null,
125
+ messageCount: 2,
126
+ firstMessageAt: AT,
127
+ lastMessageAt: LATER,
128
+ createdAt: AT,
129
+ updatedAt: LATER
130
+ },
131
+ {
132
+ id: FEATURE_THREAD_ID,
133
+ channel: "email",
134
+ inboxAddress: INBOX,
135
+ subject: "Could you add CSV export?",
136
+ fromAddress: EXAMPLE_ALAN.email,
137
+ fromName: EXAMPLE_ALAN.name,
138
+ senderAuthenticated: true,
139
+ userId: EXAMPLE_ALAN.id,
140
+ accountLinkSource: "email_address",
141
+ category: "feature_request",
142
+ priority: "low",
143
+ sentiment: "positive",
144
+ confidence: .88,
145
+ model: MODEL,
146
+ classifiedAt: AT,
147
+ archived: true,
148
+ archivedAt: ARCHIVED_AT,
149
+ archivedBy: "example-dashboard",
150
+ messageCount: 1,
151
+ firstMessageAt: AT,
152
+ lastMessageAt: AT,
153
+ createdAt: AT,
154
+ updatedAt: ARCHIVED_AT
155
+ },
156
+ {
157
+ id: APP_THREAD_ID,
158
+ channel: "app",
159
+ inboxAddress: INBOX,
160
+ subject: "Export button does nothing on the reports screen",
161
+ fromAddress: EXAMPLE_GRACE.email,
162
+ fromName: EXAMPLE_GRACE.name,
163
+ senderAuthenticated: true,
164
+ userId: EXAMPLE_GRACE.id,
165
+ accountLinkSource: "session",
166
+ declaredCategory: "feature_request",
167
+ category: "bug_report",
168
+ priority: "normal",
169
+ sentiment: "neutral",
170
+ confidence: .91,
171
+ model: MODEL,
172
+ classifiedAt: AT,
173
+ archived: false,
174
+ archivedAt: null,
175
+ archivedBy: null,
176
+ messageCount: 1,
177
+ firstMessageAt: LATER,
178
+ lastMessageAt: LATER,
179
+ createdAt: LATER,
180
+ updatedAt: LATER
181
+ }
182
+ ]),
183
+ d1SeedGroup("app", SUPPORT_MESSAGES_TABLE, SupportMessage, [
184
+ {
185
+ id: BILLING_MESSAGE_ID,
186
+ threadId: BILLING_THREAD_ID,
187
+ direction: "inbound",
188
+ channel: "email",
189
+ submittedByUserId: null,
190
+ context: null,
191
+ mimeMessageId: "seed-billing-1@example.com",
192
+ mimeInReplyTo: null,
193
+ mimeReferences: null,
194
+ fromAddress: EXAMPLE_ADA.email,
195
+ fromName: EXAMPLE_ADA.name,
196
+ toAddress: INBOX,
197
+ subject: "I was charged twice this month",
198
+ textBody: "My card shows two charges for the same subscription on the 3rd. I have only ever had one account. Please refund the duplicate.",
199
+ htmlBody: null,
200
+ emailJobId: null,
201
+ rawKey: null,
202
+ rawBytes: null,
203
+ receivedAt: AT,
204
+ createdAt: AT
205
+ },
206
+ {
207
+ id: ACCESS_MESSAGE_ID,
208
+ threadId: ACCESS_THREAD_ID,
209
+ direction: "inbound",
210
+ channel: "email",
211
+ submittedByUserId: null,
212
+ context: null,
213
+ mimeMessageId: "seed-access-1@example.com",
214
+ mimeInReplyTo: null,
215
+ mimeReferences: null,
216
+ fromAddress: EXAMPLE_GRACE.email,
217
+ fromName: EXAMPLE_GRACE.name,
218
+ toAddress: INBOX,
219
+ subject: "Magic link never arrives",
220
+ textBody: "I have asked for a sign-in link four times today and none of them have arrived. I checked spam. Is something broken?",
221
+ htmlBody: null,
222
+ emailJobId: null,
223
+ rawKey: null,
224
+ rawBytes: null,
225
+ receivedAt: AT,
226
+ createdAt: AT
227
+ },
228
+ {
229
+ id: ACCESS_REPLY_ID,
230
+ threadId: ACCESS_THREAD_ID,
231
+ direction: "outbound",
232
+ channel: "email",
233
+ submittedByUserId: null,
234
+ context: null,
235
+ mimeMessageId: null,
236
+ mimeInReplyTo: "seed-access-1@example.com",
237
+ mimeReferences: ["seed-access-1@example.com"],
238
+ fromAddress: INBOX,
239
+ fromName: null,
240
+ toAddress: EXAMPLE_GRACE.email,
241
+ subject: "Re: Magic link never arrives",
242
+ textBody: "Hi Grace,\n\nI found the problem — your address had bounced earlier in the week and was on our suppression list. I have cleared it. Try again and it should arrive within a minute.\n\nSorry for the trouble.\n",
243
+ htmlBody: null,
244
+ emailJobId: "seed-email-job-access-1",
245
+ rawKey: null,
246
+ rawBytes: null,
247
+ receivedAt: LATER,
248
+ createdAt: LATER
249
+ },
250
+ {
251
+ id: FEATURE_MESSAGE_ID,
252
+ threadId: FEATURE_THREAD_ID,
253
+ direction: "inbound",
254
+ channel: "email",
255
+ submittedByUserId: null,
256
+ context: null,
257
+ mimeMessageId: "seed-feature-1@example.com",
258
+ mimeInReplyTo: null,
259
+ mimeReferences: null,
260
+ fromAddress: EXAMPLE_ALAN.email,
261
+ fromName: EXAMPLE_ALAN.name,
262
+ toAddress: INBOX,
263
+ subject: "Could you add CSV export?",
264
+ textBody: "Loving the product. The one thing I miss is being able to export my data as CSV for a spreadsheet. No rush.",
265
+ htmlBody: null,
266
+ emailJobId: null,
267
+ rawKey: null,
268
+ rawBytes: null,
269
+ receivedAt: AT,
270
+ createdAt: AT
271
+ },
272
+ {
273
+ id: APP_MESSAGE_ID,
274
+ threadId: APP_THREAD_ID,
275
+ direction: "inbound",
276
+ channel: "app",
277
+ submittedByUserId: EXAMPLE_GRACE.id,
278
+ context: {
279
+ screen: "reports",
280
+ appVersion: "2.4.1",
281
+ platform: "web",
282
+ environment: "prod",
283
+ locale: "en-GB"
284
+ },
285
+ mimeMessageId: `${APP_MESSAGE_ID}@help.example.com`,
286
+ mimeInReplyTo: null,
287
+ mimeReferences: null,
288
+ fromAddress: EXAMPLE_GRACE.email,
289
+ fromName: EXAMPLE_GRACE.name,
290
+ toAddress: INBOX,
291
+ subject: "Export button does nothing on the reports screen",
292
+ textBody: "I press Export on the reports screen and nothing happens. No download, no error. It worked last week. Chrome, if that helps.",
293
+ htmlBody: null,
294
+ emailJobId: null,
295
+ rawKey: null,
296
+ rawBytes: null,
297
+ receivedAt: LATER,
298
+ createdAt: LATER
299
+ }
300
+ ]),
301
+ d1SeedGroup("app", SUPPORT_CLASSIFICATIONS_TABLE, SupportClassification, [
302
+ {
303
+ id: BILLING_CLASSIFICATION_ID,
304
+ threadId: BILLING_THREAD_ID,
305
+ messageId: BILLING_MESSAGE_ID,
306
+ category: "billing",
307
+ priority: "urgent",
308
+ sentiment: "angry",
309
+ confidence: .94,
310
+ model: MODEL,
311
+ createdAt: AT
312
+ },
313
+ {
314
+ id: ACCESS_CLASSIFICATION_ID,
315
+ threadId: ACCESS_THREAD_ID,
316
+ messageId: ACCESS_MESSAGE_ID,
317
+ category: "account_access",
318
+ priority: "normal",
319
+ sentiment: "frustrated",
320
+ confidence: .81,
321
+ model: MODEL,
322
+ createdAt: AT
323
+ },
324
+ {
325
+ id: APP_CLASSIFICATION_ID,
326
+ threadId: APP_THREAD_ID,
327
+ messageId: APP_MESSAGE_ID,
328
+ category: "bug_report",
329
+ priority: "normal",
330
+ sentiment: "neutral",
331
+ confidence: .91,
332
+ model: MODEL,
333
+ createdAt: LATER
334
+ },
335
+ {
336
+ id: FEATURE_CLASSIFICATION_ID,
337
+ threadId: FEATURE_THREAD_ID,
338
+ messageId: FEATURE_MESSAGE_ID,
339
+ category: "feature_request",
340
+ priority: "low",
341
+ sentiment: "positive",
342
+ confidence: .88,
343
+ model: MODEL,
344
+ createdAt: AT
345
+ }
346
+ ])
347
+ ]
348
+ });
349
+ //#endregion
350
+ export { supportExampleSeed };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The inbox page bounds, in the one module that has no reason to import anything (#430).
3
+ *
4
+ * `http/schemas.ts` bounds a caller's `limit` with the same number the reader clamps to, and it used to
5
+ * read it out of `store/threads.ts` — the Kysely reader, which brings `kysely`, `kysely-d1` and
6
+ * `@cloudflare/workers-types` with it. A request schema is a client's business: a management client
7
+ * building a call must be able to compile the shape it may send, in a browser, with no Worker types in
8
+ * reach. So the two numbers moved and the reader kept the query.
9
+ *
10
+ * **Both moved, not only the one the schema needs.** They are read together — a default clamped into a
11
+ * maximum, on one line, in two queries — and a pair split across two modules is how the next person
12
+ * picks the wrong one.
13
+ */
14
+ /** How many threads a page holds by default. */
15
+ export declare const DEFAULT_PAGE_SIZE = 25;
16
+ /** The most a caller may ask for in one page. */
17
+ export declare const MAX_PAGE_SIZE = 100;
18
+ //# sourceMappingURL=paging.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paging.d.ts","sourceRoot":"","sources":["../../src/store/paging.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;GAYG;AAEH,gDAAgD;AAChD,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAEpC,iDAAiD;AACjD,eAAO,MAAM,aAAa,MAAM,CAAC"}
@@ -0,0 +1,20 @@
1
+ //#region src/store/paging.ts
2
+ /**
3
+ * The inbox page bounds, in the one module that has no reason to import anything (#430).
4
+ *
5
+ * `http/schemas.ts` bounds a caller's `limit` with the same number the reader clamps to, and it used to
6
+ * read it out of `store/threads.ts` — the Kysely reader, which brings `kysely`, `kysely-d1` and
7
+ * `@cloudflare/workers-types` with it. A request schema is a client's business: a management client
8
+ * building a call must be able to compile the shape it may send, in a browser, with no Worker types in
9
+ * reach. So the two numbers moved and the reader kept the query.
10
+ *
11
+ * **Both moved, not only the one the schema needs.** They are read together — a default clamped into a
12
+ * maximum, on one line, in two queries — and a pair split across two modules is how the next person
13
+ * picks the wrong one.
14
+ */
15
+ /** How many threads a page holds by default. */
16
+ const DEFAULT_PAGE_SIZE = 25;
17
+ /** The most a caller may ask for in one page. */
18
+ const MAX_PAGE_SIZE = 100;
19
+ //#endregion
20
+ export { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE };
@@ -0,0 +1,67 @@
1
+ import { type Expression, type SqlBool } from "kysely";
2
+ import { type SupportDatabase } from "../data/tables";
3
+ /**
4
+ * Turn a user's words into an FTS5 MATCH expression.
5
+ *
6
+ * Every token is quoted and the whole thing is ANDed. Quoting is what stops FTS5's own query syntax
7
+ * from leaking through: a bare `NOT`, `OR`, `*` or `(` is an operator to FTS5, so a customer whose
8
+ * surname is a keyword — or anyone deliberately probing — would otherwise change the shape of the
9
+ * query rather than search for a word. This is the same "input is data, never syntax" rule the rest
10
+ * of the package applies to model output and to HTML.
11
+ */
12
+ export declare function ftsQuery(term: string): string;
13
+ /** Whether a term has anything searchable left after tokenizing. */
14
+ export declare function isSearchable(term: string, fts: boolean): boolean;
15
+ /**
16
+ * A predicate matching threads whose messages contain `term`.
17
+ *
18
+ * Correlated on the thread id so it drops into a `where` beside every other filter. The subquery is
19
+ * `exists`, so it stops at the first matching message rather than counting them.
20
+ */
21
+ export declare function searchPredicate(term: string, options: {
22
+ fts: boolean;
23
+ }): Expression<SqlBool>;
24
+ /**
25
+ * Add a message to the full-text index, replacing any row already there for it.
26
+ *
27
+ * **Remove-then-insert, not a bare insert** — the same shape the CMS `syncFts` uses, and for the
28
+ * reason application-managed indexes always need it: an FTS5 table has no primary key and no unique
29
+ * constraint to lean on, so a second call for the same message would silently add a second copy and
30
+ * the thread would start appearing twice in its own search results. Making the write idempotent means
31
+ * a retry, a reindex, and a first index are all the same operation, which is the property every other
32
+ * write path in this repo is built on.
33
+ *
34
+ * Best-effort by contract: a failed index write must never lose a customer's message, so the caller
35
+ * logs and carries on — the row is still there, still readable, still repairable by
36
+ * {@link reindexThread}, and only missing from one search box.
37
+ */
38
+ export declare function indexMessage(db: SupportDatabase, entry: {
39
+ threadId: string;
40
+ messageId: string;
41
+ subject: string;
42
+ body: string;
43
+ }): Promise<void>;
44
+ /**
45
+ * Rebuild the index for one thread — delete its rows, then re-add every message.
46
+ *
47
+ * The repair path for the failure mode application-side indexing has and triggers would not: a
48
+ * message stored while the index write failed. Also what a reindex after a tokenizer change runs.
49
+ */
50
+ export declare function reindexThread(db: SupportDatabase, threadId: string): Promise<number>;
51
+ /**
52
+ * Rebuild the whole index from the messages table, in pages.
53
+ *
54
+ * **Creating the index is not the same as populating it**, and that gap is the one failure a search
55
+ * box must not have: an adopter who turns `search.fts` on after mail already exists gets an empty
56
+ * virtual table, and because the table now *exists* the `LIKE` fallback never fires — so the inbox
57
+ * answers "no matches" for a term that is demonstrably in the body. Silently returning nothing is
58
+ * worse than returning slowly, so provisioning backfills immediately after it creates.
59
+ *
60
+ * Paged on `(receivedAt, id)` rather than an offset, for the same reason the inbox is: rows arriving
61
+ * during a long backfill would shift an offset underneath it and skip messages.
62
+ */
63
+ export declare function reindexAll(db: SupportDatabase, options?: {
64
+ pageSize?: number;
65
+ onProgress?: (indexed: number) => void;
66
+ }): Promise<number>;
67
+ //# sourceMappingURL=search.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../src/store/search.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,UAAU,EAAE,KAAK,OAAO,EAAO,MAAM,QAAQ,CAAC;AAC5D,OAAO,EAA0B,KAAK,eAAe,EAAE,MAAM,gBAAgB,CAAC;AA+B9E;;;;;;;;GAQG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAS7C;AAED,oEAAoE;AACpE,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAEhE;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE;IAAE,GAAG,EAAE,OAAO,CAAA;CAAE,GAAG,UAAU,CAAC,OAAO,CAAC,CAkB5F;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,YAAY,CAChC,EAAE,EAAE,eAAe,EACnB,KAAK,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC5E,OAAO,CAAC,IAAI,CAAC,CAMf;AAED;;;;;GAKG;AACH,wBAAsB,aAAa,CAAC,EAAE,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAkB1F;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,UAAU,CAC9B,EAAE,EAAE,eAAe,EACnB,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;CAAO,GAC1E,OAAO,CAAC,MAAM,CAAC,CA4CjB"}