@remit/search-service 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/package.json +66 -0
  2. package/src/anchor.test.ts +164 -0
  3. package/src/anchor.ts +127 -0
  4. package/src/backends/bedrock.test.ts +148 -0
  5. package/src/backends/bedrock.ts +105 -0
  6. package/src/backends/memory.test.ts +168 -0
  7. package/src/backends/memory.ts +152 -0
  8. package/src/backends/pgvector.integ.test.ts +174 -0
  9. package/src/backends/pgvector.ts +306 -0
  10. package/src/backends/runtime-import.ts +16 -0
  11. package/src/backends/s3-vectors.test.ts +929 -0
  12. package/src/backends/s3-vectors.ts +383 -0
  13. package/src/backends/sqlite-vec.integ.test.ts +144 -0
  14. package/src/backends/sqlite-vec.ts +250 -0
  15. package/src/bedrock.ts +4 -0
  16. package/src/chunking/chunker.test.ts +79 -0
  17. package/src/chunking/chunker.ts +56 -0
  18. package/src/chunking/entities.test.ts +82 -0
  19. package/src/chunking/entities.ts +74 -0
  20. package/src/chunking/entropy.test.ts +98 -0
  21. package/src/chunking/entropy.ts +161 -0
  22. package/src/chunking/keys.ts +22 -0
  23. package/src/chunking/structured.test.ts +120 -0
  24. package/src/chunking/structured.ts +79 -0
  25. package/src/content-hash.test.ts +27 -0
  26. package/src/content-hash.ts +10 -0
  27. package/src/embeddings.test.ts +28 -0
  28. package/src/embeddings.ts +149 -0
  29. package/src/from-env.test.ts +62 -0
  30. package/src/from-env.ts +130 -0
  31. package/src/index.ts +71 -0
  32. package/src/pgvector.ts +4 -0
  33. package/src/s3-vectors.ts +5 -0
  34. package/src/search.test.ts +772 -0
  35. package/src/search.ts +395 -0
  36. package/src/semantic-search.integ.test.ts +130 -0
  37. package/src/sqlite-vec.ts +4 -0
  38. package/src/types.ts +155 -0
  39. package/tsconfig.json +8 -0
@@ -0,0 +1,383 @@
1
+ import {
2
+ DeleteVectorsCommand,
3
+ GetVectorsCommand,
4
+ PutVectorsCommand,
5
+ QueryVectorsCommand,
6
+ S3VectorsClient,
7
+ } from "@aws-sdk/client-s3vectors";
8
+ import { MessageCategory } from "@remit/domain-enums";
9
+ import type { DocumentType } from "@smithy/types";
10
+ import { candidateChunkKeys } from "../chunking/keys.js";
11
+ import type {
12
+ ChunkMetadata,
13
+ VectorMatch,
14
+ VectorQuery,
15
+ VectorQueryFilter,
16
+ VectorRecord,
17
+ } from "../types.js";
18
+ import type { VectorStoreService } from "./memory.js";
19
+
20
+ // S3 Vectors rejects any metadata value that is not a string, number,
21
+ // boolean, or array of those. Address-shaped objects (sender/recipients) and
22
+ // any other nested object must be flattened to a scalar before upsert or the
23
+ // PutVectors call dead-letters with a ValidationException.
24
+ type AddressLike = {
25
+ name?: string | null;
26
+ email?: string;
27
+ address?: string;
28
+ mailbox?: string;
29
+ host?: string;
30
+ };
31
+
32
+ const isAddressLike = (value: Record<string, unknown>): value is AddressLike =>
33
+ typeof value.email === "string" ||
34
+ typeof value.address === "string" ||
35
+ (typeof value.mailbox === "string" && typeof value.host === "string");
36
+
37
+ const formatAddress = (addr: AddressLike): string => {
38
+ const email =
39
+ addr.email ??
40
+ addr.address ??
41
+ (addr.mailbox && addr.host ? `${addr.mailbox}@${addr.host}` : "");
42
+ const name = typeof addr.name === "string" ? addr.name.trim() : "";
43
+ if (name.length > 0) return `${name} <${email}>`;
44
+ return email;
45
+ };
46
+
47
+ const isScalar = (value: unknown): value is string | number | boolean =>
48
+ typeof value === "string" ||
49
+ typeof value === "number" ||
50
+ typeof value === "boolean";
51
+
52
+ // Flatten a single metadata value to an S3-Vectors-safe scalar (or array of
53
+ // scalars). Objects become display strings; arrays of objects become arrays of
54
+ // strings. The result is guaranteed to satisfy the S3 Vectors constraint.
55
+ const flattenMetadataValue = (value: unknown): DocumentType => {
56
+ if (value === null || value === undefined) return null;
57
+ if (isScalar(value)) return value;
58
+ if (Array.isArray(value)) {
59
+ return value.map((item) => {
60
+ if (item === null || item === undefined) return "";
61
+ if (isScalar(item)) return item;
62
+ if (typeof item === "object" && isAddressLike(item as AddressLike)) {
63
+ return formatAddress(item as AddressLike);
64
+ }
65
+ return JSON.stringify(item);
66
+ });
67
+ }
68
+ if (typeof value === "object") {
69
+ const obj = value as Record<string, unknown>;
70
+ if (isAddressLike(obj)) return formatAddress(obj);
71
+ return JSON.stringify(obj);
72
+ }
73
+ throw new Error(
74
+ `Cannot convert metadata value of type ${typeof value} to a scalar`,
75
+ );
76
+ };
77
+
78
+ // PutVectors always sends one flat metadata document per vector — S3 Vectors has no
79
+ // per-put "filterable vs non-filterable" split. Which keys count against the 2 KB
80
+ // filterable budget (vs the 40 KB total budget) is decided entirely by the index's
81
+ // CreateIndex-time `metadataConfiguration.nonFilterableMetadataKeys` config
82
+ // (CfnIndex in infra/constructs/s3vectors/remit-vector-index/index.ts), not by this
83
+ // put path. Declaring `textPreview` non-filterable there would need a CDK change that
84
+ // forces index replacement and a full re-index — not planned. `textPreview` is written
85
+ // as ordinary (filterable) metadata by design, bounded to a fixed byte budget
86
+ // (TEXT_PREVIEW_MAX_BYTES in search.ts) so it — plus every other key here — always
87
+ // fits the 2 KB filterable cap.
88
+ const toMetadataDocument = (metadata: ChunkMetadata): DocumentType => {
89
+ const out: { [k: string]: DocumentType } = {};
90
+ for (const [k, v] of Object.entries(metadata)) {
91
+ if (v === undefined) continue;
92
+ const flattened = flattenMetadataValue(v);
93
+ // S3 Vectors rejects null metadata values. Omit the key entirely (e.g.
94
+ // fromName for a sender with no display name) rather than emit null.
95
+ if (flattened === null) continue;
96
+ out[k] = flattened;
97
+ }
98
+ return out;
99
+ };
100
+
101
+ const PUT_BATCH_SIZE = 100;
102
+ const DELETE_BATCH_SIZE = 100;
103
+ // AWS caps GetVectors at 100 keys per call (s3-vectors-limitations).
104
+ const GET_BATCH_SIZE = 100;
105
+
106
+ const MESSAGE_CATEGORIES = new Set<string>(Object.values(MessageCategory));
107
+
108
+ const isMessageCategory = (
109
+ value: unknown,
110
+ ): value is ChunkMetadata["category"] =>
111
+ typeof value === "string" && MESSAGE_CATEGORIES.has(value);
112
+
113
+ const readContentHash = (metadata: unknown): string | undefined => {
114
+ if (typeof metadata !== "object" || metadata === null) return undefined;
115
+ const hash = (metadata as Record<string, unknown>).contentHash;
116
+ return typeof hash === "string" ? hash : undefined;
117
+ };
118
+
119
+ export interface S3VectorsBackendConfig {
120
+ client?: S3VectorsClient;
121
+ region?: string;
122
+ vectorBucketName: string;
123
+ indexName: string;
124
+ }
125
+
126
+ const isStringArray = (value: unknown): value is string[] =>
127
+ Array.isArray(value) && value.every((v) => typeof v === "string");
128
+
129
+ const isNumberArray = (value: unknown): value is number[] =>
130
+ Array.isArray(value) && value.every((v) => typeof v === "number");
131
+
132
+ const toMetadata = (raw: unknown): ChunkMetadata => {
133
+ if (typeof raw !== "object" || raw === null) {
134
+ throw new Error("S3 Vectors metadata is not an object");
135
+ }
136
+ const obj = raw as Record<string, unknown>;
137
+ if (
138
+ typeof obj.messageId !== "string" ||
139
+ typeof obj.threadId !== "string" ||
140
+ typeof obj.accountConfigId !== "string" ||
141
+ typeof obj.chunkType !== "string" ||
142
+ typeof obj.sentDate !== "number" ||
143
+ typeof obj.isRead !== "boolean" ||
144
+ typeof obj.hasAttachment !== "boolean" ||
145
+ typeof obj.hasStars !== "boolean" ||
146
+ !isStringArray(obj.mailboxIds)
147
+ ) {
148
+ throw new Error(`S3 Vectors metadata is malformed: ${JSON.stringify(obj)}`);
149
+ }
150
+ const fileTypes =
151
+ obj.fileTypes !== undefined && isStringArray(obj.fileTypes)
152
+ ? obj.fileTypes
153
+ : undefined;
154
+ // fromName and subject are optional display fields added after initial
155
+ // deployment. Pre-enrichment vectors will not have them; treat as absent.
156
+ const fromName =
157
+ obj.fromName === null
158
+ ? null
159
+ : typeof obj.fromName === "string"
160
+ ? obj.fromName
161
+ : undefined;
162
+ const subject = typeof obj.subject === "string" ? obj.subject : undefined;
163
+ const category = isMessageCategory(obj.category) ? obj.category : undefined;
164
+ // textPreview is absent on vectors written before hybrid re-ranking shipped;
165
+ // treat as absent (score-neutral re-rank), same as the other display fields.
166
+ const textPreview =
167
+ typeof obj.textPreview === "string" ? obj.textPreview : undefined;
168
+ return {
169
+ messageId: obj.messageId,
170
+ threadId: obj.threadId,
171
+ accountConfigId: obj.accountConfigId,
172
+ mailboxIds: obj.mailboxIds,
173
+ chunkType: obj.chunkType as ChunkMetadata["chunkType"],
174
+ sentDate: obj.sentDate,
175
+ isRead: obj.isRead,
176
+ hasAttachment: obj.hasAttachment,
177
+ hasStars: obj.hasStars,
178
+ fileTypes,
179
+ ...(fromName !== undefined ? { fromName } : {}),
180
+ ...(subject !== undefined ? { subject } : {}),
181
+ ...(category !== undefined ? { category } : {}),
182
+ ...(textPreview !== undefined ? { textPreview } : {}),
183
+ };
184
+ };
185
+
186
+ // S3 Vectors does NOT accept implicit AND across multiple metadata keys — a flat
187
+ // multi-key object (e.g. { accountConfigId, mailboxIds }) is rejected with
188
+ // `ValidationException: Invalid filter`. Multiple conditions must be combined
189
+ // explicitly with $and, each as its own single-key object. A single condition is
190
+ // accepted bare, so we don't needlessly wrap it.
191
+ const buildFilterExpression = (
192
+ filter: VectorQueryFilter | undefined,
193
+ ): DocumentType | undefined => {
194
+ if (!filter) return undefined;
195
+ const conditions: { [k: string]: DocumentType }[] = [];
196
+ if (filter.accountConfigId !== undefined) {
197
+ conditions.push({ accountConfigId: filter.accountConfigId });
198
+ }
199
+ if (filter.mailboxId !== undefined) {
200
+ conditions.push({
201
+ mailboxIds: { $in: [filter.mailboxId] as DocumentType[] },
202
+ });
203
+ }
204
+ if (filter.chunkType !== undefined) {
205
+ conditions.push({ chunkType: filter.chunkType });
206
+ }
207
+ if (filter.category !== undefined) {
208
+ conditions.push({ category: filter.category });
209
+ }
210
+ if (filter.hasAttachment !== undefined) {
211
+ conditions.push({ hasAttachment: filter.hasAttachment });
212
+ }
213
+ if (filter.hasStars !== undefined) {
214
+ conditions.push({ hasStars: filter.hasStars });
215
+ }
216
+ if (filter.isRead !== undefined) {
217
+ conditions.push({ isRead: filter.isRead });
218
+ }
219
+ if (filter.sentDateRange) {
220
+ const range: { [k: string]: DocumentType } = {};
221
+ if (filter.sentDateRange.from !== undefined) {
222
+ range.$gte = filter.sentDateRange.from;
223
+ }
224
+ if (filter.sentDateRange.to !== undefined) {
225
+ range.$lte = filter.sentDateRange.to;
226
+ }
227
+ if (Object.keys(range).length > 0) conditions.push({ sentDate: range });
228
+ }
229
+ if (conditions.length === 0) return undefined;
230
+ if (conditions.length === 1) return conditions[0];
231
+ return { $and: conditions as DocumentType[] };
232
+ };
233
+
234
+ const chunkArray = <T>(arr: T[], size: number): T[][] => {
235
+ const out: T[][] = [];
236
+ for (let i = 0; i < arr.length; i += size) {
237
+ out.push(arr.slice(i, i + size));
238
+ }
239
+ return out;
240
+ };
241
+
242
+ export class S3VectorsBackend implements VectorStoreService {
243
+ private client: S3VectorsClient;
244
+ private vectorBucketName: string;
245
+ private indexName: string;
246
+
247
+ constructor(config: S3VectorsBackendConfig) {
248
+ this.client =
249
+ config.client ?? new S3VectorsClient({ region: config.region });
250
+ this.vectorBucketName = config.vectorBucketName;
251
+ this.indexName = config.indexName;
252
+ }
253
+
254
+ upsert = async (vectors: VectorRecord[]): Promise<void> => {
255
+ for (const batch of chunkArray(vectors, PUT_BATCH_SIZE)) {
256
+ const cmd = new PutVectorsCommand({
257
+ vectorBucketName: this.vectorBucketName,
258
+ indexName: this.indexName,
259
+ vectors: batch.map((v) => ({
260
+ key: v.chunkId,
261
+ data: { float32: v.vector },
262
+ metadata: toMetadataDocument(v.metadata),
263
+ })),
264
+ });
265
+ await this.client.send(cmd);
266
+ }
267
+ };
268
+
269
+ // S3 Vectors returns a large topK across pages: one QueryVectors call yields a
270
+ // single page plus a `nextToken` when more results remain. Follow the token,
271
+ // re-issuing with the same queryVector/topK/filter, until the requested topK is
272
+ // filled or no token remains — a single-page query stops after one call. The
273
+ // loop is bounded by topK (never issues a request once topK matches are in
274
+ // hand), so a 4000-topK back-apply reads every page instead of silently capping
275
+ // at the first.
276
+ query = async (params: VectorQuery): Promise<VectorMatch[]> => {
277
+ const filter = buildFilterExpression(params.filter);
278
+ const out: VectorMatch[] = [];
279
+ let nextToken: string | undefined;
280
+ do {
281
+ const cmd = new QueryVectorsCommand({
282
+ vectorBucketName: this.vectorBucketName,
283
+ indexName: this.indexName,
284
+ topK: params.topK,
285
+ queryVector: { float32: params.vector },
286
+ filter,
287
+ returnMetadata: true,
288
+ returnDistance: true,
289
+ nextToken,
290
+ });
291
+ const response = await this.client.send(cmd);
292
+ for (const v of response.vectors ?? []) {
293
+ if (typeof v.key !== "string") continue;
294
+ const distance = v.distance ?? 0;
295
+ const score = 1 - distance;
296
+ out.push({
297
+ chunkId: v.key,
298
+ score,
299
+ metadata: toMetadata(v.metadata),
300
+ });
301
+ }
302
+ nextToken = response.nextToken || undefined;
303
+ } while (nextToken && out.length < params.topK);
304
+ return out.slice(0, params.topK);
305
+ };
306
+
307
+ // Read the stored content hash for each chunk key via GetVectors — addressed
308
+ // by key, returnMetadata only (no vector data), never an index-wide scan. A
309
+ // read, not a write: the idempotency check costs cheap GETs, never PUTs.
310
+ // Keys with no stored vector are simply absent from the response.
311
+ existingContentHashes = async (
312
+ chunkIds: string[],
313
+ ): Promise<Map<string, string>> => {
314
+ const out = new Map<string, string>();
315
+ for (const batch of chunkArray(chunkIds, GET_BATCH_SIZE)) {
316
+ const cmd = new GetVectorsCommand({
317
+ vectorBucketName: this.vectorBucketName,
318
+ indexName: this.indexName,
319
+ keys: batch,
320
+ returnData: false,
321
+ returnMetadata: true,
322
+ });
323
+ const response = await this.client.send(cmd);
324
+ for (const v of response.vectors ?? []) {
325
+ if (typeof v.key !== "string") continue;
326
+ const hash = readContentHash(v.metadata);
327
+ if (hash !== undefined) out.set(v.key, hash);
328
+ }
329
+ }
330
+ return out;
331
+ };
332
+
333
+ // Read a message's chunk vectors (data + metadata) via GetVectors, addressed
334
+ // by the message's deterministic candidate keys — never a scan. GetVectors
335
+ // omits keys with no stored vector, so the result holds only chunks the
336
+ // message actually indexed. Backs the filter-anchor pooling (RFC 034
337
+ // Decision 2.1).
338
+ getByMessage = async (messageId: string): Promise<VectorRecord[]> => {
339
+ const out: VectorRecord[] = [];
340
+ const keys = candidateChunkKeys(messageId);
341
+ for (const batch of chunkArray(keys, GET_BATCH_SIZE)) {
342
+ const cmd = new GetVectorsCommand({
343
+ vectorBucketName: this.vectorBucketName,
344
+ indexName: this.indexName,
345
+ keys: batch,
346
+ returnData: true,
347
+ returnMetadata: true,
348
+ });
349
+ const response = await this.client.send(cmd);
350
+ for (const v of response.vectors ?? []) {
351
+ if (typeof v.key !== "string") continue;
352
+ const vector = v.data?.float32;
353
+ if (!isNumberArray(vector)) continue;
354
+ out.push({
355
+ chunkId: v.key,
356
+ vector,
357
+ metadata: toMetadata(v.metadata),
358
+ });
359
+ }
360
+ }
361
+ return out;
362
+ };
363
+
364
+ // A message's chunks live under deterministic keys, so we delete them by
365
+ // address — never by listing the index. DeleteVectors ignores keys that
366
+ // aren't present, so addressing the full candidate set is safe and the cost
367
+ // is a fixed handful of requests per message regardless of index size.
368
+ delete = async (filter: { messageId: string }): Promise<void> => {
369
+ const keys = candidateChunkKeys(filter.messageId);
370
+ for (const batch of chunkArray(keys, DELETE_BATCH_SIZE)) {
371
+ const cmd = new DeleteVectorsCommand({
372
+ vectorBucketName: this.vectorBucketName,
373
+ indexName: this.indexName,
374
+ keys: batch,
375
+ });
376
+ await this.client.send(cmd);
377
+ }
378
+ };
379
+ }
380
+
381
+ export const createS3VectorsBackend = (
382
+ config: S3VectorsBackendConfig,
383
+ ): S3VectorsBackend => new S3VectorsBackend(config);
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Exercises the sqlite-vec store against a real in-memory vec0 table (the local
3
+ * embedded-vector stack). Proves upsert, cosine ranking, content-hash lookup,
4
+ * the getByMessage anchor-pooling read path, and delete round-trip through the
5
+ * native extension.
6
+ *
7
+ * Gated behind RUN_INTEG_TESTS because it loads the native better-sqlite3 and
8
+ * sqlite-vec binaries, matching the pgvector integration suite.
9
+ *
10
+ * npm run test:integ -w packages/search-service
11
+ */
12
+ import assert from "node:assert";
13
+ import { after, before, describe, test } from "node:test";
14
+ import type { ChunkMetadata, VectorRecord } from "../types.js";
15
+ import type { VectorStoreService } from "./memory.js";
16
+ import { createSqliteVectorStore } from "./sqlite-vec.js";
17
+
18
+ const RUN = process.env.RUN_INTEG_TESTS === "1";
19
+ const DIMENSIONS = 4;
20
+
21
+ const meta = (
22
+ over: Partial<ChunkMetadata> & { messageId: string },
23
+ ): ChunkMetadata => ({
24
+ threadId: "t-1",
25
+ accountConfigId: "acc-1",
26
+ mailboxIds: ["mb-1"],
27
+ chunkType: "body",
28
+ sentDate: 1000,
29
+ isRead: false,
30
+ hasAttachment: false,
31
+ hasStars: false,
32
+ ...over,
33
+ });
34
+
35
+ const record = (
36
+ chunkId: string,
37
+ vector: number[],
38
+ over: Partial<ChunkMetadata> & { messageId: string },
39
+ ): VectorRecord => ({ chunkId, vector, metadata: meta(over) });
40
+
41
+ describe("sqlite-vec store (integration)", { skip: !RUN }, () => {
42
+ let store: VectorStoreService;
43
+
44
+ before(() => {
45
+ store = createSqliteVectorStore({
46
+ path: ":memory:",
47
+ dimensions: DIMENSIONS,
48
+ });
49
+ });
50
+
51
+ after(async () => {
52
+ await store.close?.();
53
+ });
54
+
55
+ test("upsert then query ranks by cosine similarity", async () => {
56
+ await store.upsert([
57
+ record("c-x", [1, 0, 0, 0], { messageId: "m-x", contentHash: "hx" }),
58
+ record("c-y", [0, 1, 0, 0], { messageId: "m-y", contentHash: "hy" }),
59
+ record("c-z", [0.9, 0.1, 0, 0], { messageId: "m-z", contentHash: "hz" }),
60
+ ]);
61
+
62
+ const matches = await store.query({ vector: [1, 0, 0, 0], topK: 3 });
63
+
64
+ assert.equal(matches[0].chunkId, "c-x");
65
+ assert.equal(matches[1].chunkId, "c-z");
66
+ assert.equal(matches[2].chunkId, "c-y");
67
+ assert.ok(matches[0].score > matches[1].score);
68
+ assert.ok(matches[0].score > 0.99);
69
+ });
70
+
71
+ test("existingContentHashes returns stored hashes only for known keys", async () => {
72
+ const hashes = await store.existingContentHashes(["c-x", "c-y", "missing"]);
73
+ assert.equal(hashes.get("c-x"), "hx");
74
+ assert.equal(hashes.get("c-y"), "hy");
75
+ assert.equal(hashes.has("missing"), false);
76
+ });
77
+
78
+ test("getByMessage returns every chunk of a message with its vector and metadata", async () => {
79
+ await store.upsert([
80
+ record("g-sub", [1, 0, 0, 0], {
81
+ messageId: "m-get",
82
+ chunkType: "subject",
83
+ contentHash: "hg1",
84
+ }),
85
+ record("g-body", [0, 1, 0, 0], {
86
+ messageId: "m-get",
87
+ chunkType: "body",
88
+ contentHash: "hg2",
89
+ }),
90
+ record("g-other", [0, 0, 1, 0], { messageId: "m-other" }),
91
+ ]);
92
+
93
+ const records = await store.getByMessage("m-get");
94
+
95
+ const byId = new Map(records.map((r) => [r.chunkId, r]));
96
+ assert.equal(records.length, 2, "only the message's own chunks");
97
+ assert.deepEqual(byId.get("g-sub")?.vector, [1, 0, 0, 0]);
98
+ assert.deepEqual(byId.get("g-body")?.vector, [0, 1, 0, 0]);
99
+ assert.equal(byId.get("g-sub")?.metadata.chunkType, "subject");
100
+ assert.equal(byId.get("g-body")?.metadata.messageId, "m-get");
101
+ });
102
+
103
+ test("getByMessage returns an empty array for an unknown message", async () => {
104
+ assert.deepEqual(await store.getByMessage("m-absent"), []);
105
+ });
106
+
107
+ test("indexes a categoryless chunk and its category stays absent under a category filter", async () => {
108
+ await store.upsert([
109
+ record("cat-none", [1, 0, 0, 0], { messageId: "m-cat-none" }),
110
+ record("cat-news", [1, 0, 0, 0], {
111
+ messageId: "m-cat-news",
112
+ category: "newsletter",
113
+ }),
114
+ ]);
115
+
116
+ const scoped = await store.query({
117
+ vector: [1, 0, 0, 0],
118
+ topK: 10,
119
+ filter: { category: "newsletter" },
120
+ });
121
+ assert.deepEqual(
122
+ scoped.map((m) => m.chunkId),
123
+ ["cat-news"],
124
+ "a category filter excludes the categoryless chunk",
125
+ );
126
+
127
+ const [record0] = await store.getByMessage("m-cat-none");
128
+ assert.equal(
129
+ record0.metadata.category,
130
+ undefined,
131
+ "the categoryless chunk reads back with no category, not an empty string",
132
+ );
133
+ });
134
+
135
+ test("delete removes every chunk of a message", async () => {
136
+ await store.upsert([
137
+ record("d-1", [1, 0, 0, 0], { messageId: "m-del" }),
138
+ record("d-2", [0, 1, 0, 0], { messageId: "m-del" }),
139
+ ]);
140
+ await store.delete({ messageId: "m-del" });
141
+ const hashes = await store.existingContentHashes(["d-1", "d-2"]);
142
+ assert.equal(hashes.size, 0);
143
+ });
144
+ });