@remit/storage-service 0.0.10 → 0.0.12
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/package.json +1 -1
- package/src/backends/filesystem.ts +22 -107
- package/src/backends/s3.test.ts +0 -154
- package/src/backends/s3.ts +23 -137
- package/src/index.ts +0 -7
- package/src/outbox-attachment-retrieve.test.ts +56 -0
- package/src/storage.test.ts +0 -240
- package/src/storage.ts +18 -167
package/package.json
CHANGED
|
@@ -29,9 +29,6 @@ import {
|
|
|
29
29
|
buildBodyPartKey,
|
|
30
30
|
buildDeduplicatedKey,
|
|
31
31
|
buildExportArchiveKey,
|
|
32
|
-
buildExtractedPrefix,
|
|
33
|
-
buildExtractedSkippedKey,
|
|
34
|
-
buildExtractedTextKey,
|
|
35
32
|
buildMessageBodyKey,
|
|
36
33
|
buildOutboxAttachmentKey,
|
|
37
34
|
buildOutboxAttachmentPrefix,
|
|
@@ -174,105 +171,6 @@ export const createFilesystemStorageService = (
|
|
|
174
171
|
: buffer;
|
|
175
172
|
};
|
|
176
173
|
|
|
177
|
-
const storeExtractedText: StorageService["storeExtractedText"] = (params) => {
|
|
178
|
-
const { accountConfigId, accountId, messageId, partPath, text } = params;
|
|
179
|
-
return storeInternal({
|
|
180
|
-
key: buildExtractedTextKey(
|
|
181
|
-
accountConfigId,
|
|
182
|
-
accountId,
|
|
183
|
-
messageId,
|
|
184
|
-
partPath,
|
|
185
|
-
),
|
|
186
|
-
content: Buffer.from(text, "utf8"),
|
|
187
|
-
});
|
|
188
|
-
};
|
|
189
|
-
|
|
190
|
-
const storeExtractedSkipped: StorageService["storeExtractedSkipped"] = (
|
|
191
|
-
params,
|
|
192
|
-
) => {
|
|
193
|
-
const { accountConfigId, accountId, messageId, partPath, marker } = params;
|
|
194
|
-
return storeInternal({
|
|
195
|
-
key: buildExtractedSkippedKey(
|
|
196
|
-
accountConfigId,
|
|
197
|
-
accountId,
|
|
198
|
-
messageId,
|
|
199
|
-
partPath,
|
|
200
|
-
),
|
|
201
|
-
content: Buffer.from(JSON.stringify(marker), "utf8"),
|
|
202
|
-
compress: false,
|
|
203
|
-
});
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
const extractedResultExists: StorageService["extractedResultExists"] = async (
|
|
207
|
-
accountConfigId,
|
|
208
|
-
accountId,
|
|
209
|
-
messageId,
|
|
210
|
-
partPath,
|
|
211
|
-
) => {
|
|
212
|
-
const textPath = join(
|
|
213
|
-
basePath,
|
|
214
|
-
buildExtractedTextKey(accountConfigId, accountId, messageId, partPath),
|
|
215
|
-
);
|
|
216
|
-
const skippedPath = join(
|
|
217
|
-
basePath,
|
|
218
|
-
buildExtractedSkippedKey(accountConfigId, accountId, messageId, partPath),
|
|
219
|
-
);
|
|
220
|
-
const exists = (fullPath: string): Promise<boolean> =>
|
|
221
|
-
stat(fullPath)
|
|
222
|
-
.then(() => true)
|
|
223
|
-
.catch(() => false);
|
|
224
|
-
|
|
225
|
-
const [textExists, skippedExists] = await Promise.all([
|
|
226
|
-
exists(textPath),
|
|
227
|
-
exists(skippedPath),
|
|
228
|
-
]);
|
|
229
|
-
return textExists || skippedExists;
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
const retrieveExtractedText: StorageService["retrieveExtractedText"] = async (
|
|
233
|
-
accountConfigId,
|
|
234
|
-
accountId,
|
|
235
|
-
messageId,
|
|
236
|
-
partPath,
|
|
237
|
-
) => {
|
|
238
|
-
const fullPath = join(
|
|
239
|
-
basePath,
|
|
240
|
-
buildExtractedTextKey(accountConfigId, accountId, messageId, partPath),
|
|
241
|
-
);
|
|
242
|
-
const buffer = await readFile(fullPath).catch((error: unknown) => {
|
|
243
|
-
if (isStorageNotFoundError(error)) return null;
|
|
244
|
-
throw error;
|
|
245
|
-
});
|
|
246
|
-
if (!buffer) return null;
|
|
247
|
-
const decoded =
|
|
248
|
-
buffer[0] === 0x1f && buffer[1] === 0x8b ? gunzipSync(buffer) : buffer;
|
|
249
|
-
return decoded.toString("utf8");
|
|
250
|
-
};
|
|
251
|
-
|
|
252
|
-
const listExtractedTexts: StorageService["listExtractedTexts"] = async (
|
|
253
|
-
accountConfigId,
|
|
254
|
-
accountId,
|
|
255
|
-
messageId,
|
|
256
|
-
) => {
|
|
257
|
-
const prefix = buildExtractedPrefix(accountConfigId, accountId, messageId);
|
|
258
|
-
const suffix = ".txt.gz";
|
|
259
|
-
const dirPath = join(basePath, prefix);
|
|
260
|
-
|
|
261
|
-
const entries = await readdir(dirPath, { recursive: true }).catch(
|
|
262
|
-
(error: unknown) => {
|
|
263
|
-
if (isStorageNotFoundError(error)) return [];
|
|
264
|
-
throw error;
|
|
265
|
-
},
|
|
266
|
-
);
|
|
267
|
-
|
|
268
|
-
return entries
|
|
269
|
-
.filter((entry) => entry.endsWith(suffix))
|
|
270
|
-
.map((entry) => ({
|
|
271
|
-
partPath: entry.slice(0, -suffix.length),
|
|
272
|
-
key: `${prefix}${entry}`,
|
|
273
|
-
}));
|
|
274
|
-
};
|
|
275
|
-
|
|
276
174
|
const storeOutboxAttachment: StorageService["storeOutboxAttachment"] = (
|
|
277
175
|
params,
|
|
278
176
|
) => {
|
|
@@ -400,6 +298,27 @@ export const createFilesystemStorageService = (
|
|
|
400
298
|
return stats ? { sizeBytes: stats.size } : null;
|
|
401
299
|
};
|
|
402
300
|
|
|
301
|
+
const retrieveOutboxAttachment: StorageService["retrieveOutboxAttachment"] = (
|
|
302
|
+
accountConfigId,
|
|
303
|
+
accountId,
|
|
304
|
+
outboxMessageId,
|
|
305
|
+
outboxAttachmentId,
|
|
306
|
+
) =>
|
|
307
|
+
readFile(
|
|
308
|
+
join(
|
|
309
|
+
basePath,
|
|
310
|
+
buildOutboxAttachmentKey(
|
|
311
|
+
accountConfigId,
|
|
312
|
+
accountId,
|
|
313
|
+
outboxMessageId,
|
|
314
|
+
outboxAttachmentId,
|
|
315
|
+
),
|
|
316
|
+
),
|
|
317
|
+
).catch((error: unknown) => {
|
|
318
|
+
if (isStorageNotFoundError(error)) return null;
|
|
319
|
+
throw error;
|
|
320
|
+
});
|
|
321
|
+
|
|
403
322
|
// This backend has no presigned anything, so the URL addresses the
|
|
404
323
|
// deployment's own upload route. Its authority is the same HMAC the read side
|
|
405
324
|
// uses on /content, under the write label, covering the storage key, the
|
|
@@ -540,15 +459,11 @@ export const createFilesystemStorageService = (
|
|
|
540
459
|
deleteOutboxAttachments,
|
|
541
460
|
deleteOutboxAttachment,
|
|
542
461
|
statOutboxAttachment,
|
|
462
|
+
retrieveOutboxAttachment,
|
|
543
463
|
createOutboxAttachmentUploadUrl,
|
|
544
464
|
storeDeduplicated,
|
|
545
465
|
storeParsedBody,
|
|
546
466
|
retrieveParsedBody,
|
|
547
|
-
storeExtractedText,
|
|
548
|
-
storeExtractedSkipped,
|
|
549
|
-
extractedResultExists,
|
|
550
|
-
retrieveExtractedText,
|
|
551
|
-
listExtractedTexts,
|
|
552
467
|
retrieveMessageBody: async (accountConfigId, accountId, messageId) => {
|
|
553
468
|
const key = buildMessageBodyKey(accountConfigId, accountId, messageId);
|
|
554
469
|
const fullPath = join(basePath, key);
|
package/src/backends/s3.test.ts
CHANGED
|
@@ -367,160 +367,6 @@ describe("createS3StorageService", () => {
|
|
|
367
367
|
);
|
|
368
368
|
assert.strictEqual(miss, null);
|
|
369
369
|
});
|
|
370
|
-
|
|
371
|
-
test("storeExtractedText writes gzipped text keyed under .../extracted/{partPath}.txt.gz and round-trips", async () => {
|
|
372
|
-
const { client, commands, stored } = createFakeS3Client();
|
|
373
|
-
const storage = createS3StorageService(client, "bucket");
|
|
374
|
-
|
|
375
|
-
const ref = await storage.storeExtractedText({
|
|
376
|
-
accountConfigId: "cfg-x",
|
|
377
|
-
accountId: "acc-x",
|
|
378
|
-
messageId: "msg-x",
|
|
379
|
-
partPath: "1.2",
|
|
380
|
-
text: "extracted attachment text",
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
assert.strictEqual(
|
|
384
|
-
ref.storageKey,
|
|
385
|
-
"accounts/cfg-x/acc-x/messages/msg-x/extracted/1.2.txt.gz",
|
|
386
|
-
);
|
|
387
|
-
const put = commands.find((c) => c.name === "PutObjectCommand");
|
|
388
|
-
assert.strictEqual(put?.input.ContentEncoding, "gzip");
|
|
389
|
-
assert.strictEqual(put?.input.ContentType, "text/plain; charset=utf-8");
|
|
390
|
-
|
|
391
|
-
const entry = stored.get(
|
|
392
|
-
"accounts/cfg-x/acc-x/messages/msg-x/extracted/1.2.txt.gz",
|
|
393
|
-
);
|
|
394
|
-
assert.ok(entry);
|
|
395
|
-
assert.strictEqual(
|
|
396
|
-
gunzipSync(entry.body).toString("utf8"),
|
|
397
|
-
"extracted attachment text",
|
|
398
|
-
);
|
|
399
|
-
|
|
400
|
-
const retrieved = await storage.retrieveExtractedText(
|
|
401
|
-
"cfg-x",
|
|
402
|
-
"acc-x",
|
|
403
|
-
"msg-x",
|
|
404
|
-
"1.2",
|
|
405
|
-
);
|
|
406
|
-
assert.strictEqual(retrieved, "extracted attachment text");
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
test("retrieveExtractedText returns null on miss", async () => {
|
|
410
|
-
const { client } = createFakeS3Client();
|
|
411
|
-
const storage = createS3StorageService(client, "bucket");
|
|
412
|
-
const result = await storage.retrieveExtractedText(
|
|
413
|
-
"cfg-x",
|
|
414
|
-
"acc-x",
|
|
415
|
-
"msg-x",
|
|
416
|
-
"nope",
|
|
417
|
-
);
|
|
418
|
-
assert.strictEqual(result, null);
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
test("storeExtractedSkipped writes an uncompressed JSON marker keyed under .../extracted/{partPath}.skipped.json", async () => {
|
|
422
|
-
const { client, commands, stored } = createFakeS3Client();
|
|
423
|
-
const storage = createS3StorageService(client, "bucket");
|
|
424
|
-
|
|
425
|
-
const ref = await storage.storeExtractedSkipped({
|
|
426
|
-
accountConfigId: "cfg-s",
|
|
427
|
-
accountId: "acc-s",
|
|
428
|
-
messageId: "msg-s",
|
|
429
|
-
partPath: "3",
|
|
430
|
-
marker: { status: "failed", reason: "pdf: corrupt" },
|
|
431
|
-
});
|
|
432
|
-
|
|
433
|
-
assert.strictEqual(
|
|
434
|
-
ref.storageKey,
|
|
435
|
-
"accounts/cfg-s/acc-s/messages/msg-s/extracted/3.skipped.json",
|
|
436
|
-
);
|
|
437
|
-
const put = commands.find((c) => c.name === "PutObjectCommand");
|
|
438
|
-
assert.strictEqual(put?.input.ContentEncoding, undefined);
|
|
439
|
-
assert.strictEqual(put?.input.ContentType, "application/json");
|
|
440
|
-
|
|
441
|
-
const entry = stored.get(
|
|
442
|
-
"accounts/cfg-s/acc-s/messages/msg-s/extracted/3.skipped.json",
|
|
443
|
-
);
|
|
444
|
-
assert.ok(entry);
|
|
445
|
-
assert.deepStrictEqual(JSON.parse(entry.body.toString("utf8")), {
|
|
446
|
-
status: "failed",
|
|
447
|
-
reason: "pdf: corrupt",
|
|
448
|
-
});
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
test("extractedResultExists is true once either the text artifact or the skip marker is stored", async () => {
|
|
452
|
-
const { client } = createFakeS3Client();
|
|
453
|
-
const storage = createS3StorageService(client, "bucket");
|
|
454
|
-
|
|
455
|
-
assert.strictEqual(
|
|
456
|
-
await storage.extractedResultExists("cfg-e", "acc-e", "msg-e", "1"),
|
|
457
|
-
false,
|
|
458
|
-
);
|
|
459
|
-
|
|
460
|
-
await storage.storeExtractedText({
|
|
461
|
-
accountConfigId: "cfg-e",
|
|
462
|
-
accountId: "acc-e",
|
|
463
|
-
messageId: "msg-e",
|
|
464
|
-
partPath: "1",
|
|
465
|
-
text: "hi",
|
|
466
|
-
});
|
|
467
|
-
assert.strictEqual(
|
|
468
|
-
await storage.extractedResultExists("cfg-e", "acc-e", "msg-e", "1"),
|
|
469
|
-
true,
|
|
470
|
-
);
|
|
471
|
-
|
|
472
|
-
await storage.storeExtractedSkipped({
|
|
473
|
-
accountConfigId: "cfg-e",
|
|
474
|
-
accountId: "acc-e",
|
|
475
|
-
messageId: "msg-e",
|
|
476
|
-
partPath: "2",
|
|
477
|
-
marker: { status: "skipped", reason: "too-large" },
|
|
478
|
-
});
|
|
479
|
-
assert.strictEqual(
|
|
480
|
-
await storage.extractedResultExists("cfg-e", "acc-e", "msg-e", "2"),
|
|
481
|
-
true,
|
|
482
|
-
);
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
test("listExtractedTexts returns only .txt.gz artifacts for the given message, excluding skip markers and other messages", async () => {
|
|
486
|
-
const { client } = createFakeS3Client();
|
|
487
|
-
const storage = createS3StorageService(client, "bucket");
|
|
488
|
-
|
|
489
|
-
await storage.storeExtractedText({
|
|
490
|
-
accountConfigId: "cfg-l",
|
|
491
|
-
accountId: "acc-l",
|
|
492
|
-
messageId: "msg-l",
|
|
493
|
-
partPath: "1",
|
|
494
|
-
text: "one",
|
|
495
|
-
});
|
|
496
|
-
await storage.storeExtractedText({
|
|
497
|
-
accountConfigId: "cfg-l",
|
|
498
|
-
accountId: "acc-l",
|
|
499
|
-
messageId: "msg-l",
|
|
500
|
-
partPath: "2.1",
|
|
501
|
-
text: "two",
|
|
502
|
-
});
|
|
503
|
-
await storage.storeExtractedSkipped({
|
|
504
|
-
accountConfigId: "cfg-l",
|
|
505
|
-
accountId: "acc-l",
|
|
506
|
-
messageId: "msg-l",
|
|
507
|
-
partPath: "3",
|
|
508
|
-
marker: { status: "failed", reason: "doc: corrupt" },
|
|
509
|
-
});
|
|
510
|
-
await storage.storeExtractedText({
|
|
511
|
-
accountConfigId: "cfg-l",
|
|
512
|
-
accountId: "acc-l",
|
|
513
|
-
messageId: "other-msg",
|
|
514
|
-
partPath: "1",
|
|
515
|
-
text: "unrelated",
|
|
516
|
-
});
|
|
517
|
-
|
|
518
|
-
const items = await storage.listExtractedTexts("cfg-l", "acc-l", "msg-l");
|
|
519
|
-
assert.deepStrictEqual(items.map((i) => i.partPath).sort(), ["1", "2.1"]);
|
|
520
|
-
for (const item of items) {
|
|
521
|
-
assert.ok(item.key.endsWith(".txt.gz"));
|
|
522
|
-
}
|
|
523
|
-
});
|
|
524
370
|
});
|
|
525
371
|
|
|
526
372
|
describe("createS3StorageService: outbox attachments", () => {
|
package/src/backends/s3.ts
CHANGED
|
@@ -15,7 +15,6 @@ import { Upload } from "@aws-sdk/lib-storage";
|
|
|
15
15
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
16
16
|
import { ContentEncoding, StorageType } from "@remit/domain-enums";
|
|
17
17
|
import type {
|
|
18
|
-
ExtractedTextListItem,
|
|
19
18
|
OutboxAttachmentListItem,
|
|
20
19
|
ParsedBody,
|
|
21
20
|
StorageReference,
|
|
@@ -25,9 +24,6 @@ import {
|
|
|
25
24
|
buildBodyPartKey,
|
|
26
25
|
buildDeduplicatedKey,
|
|
27
26
|
buildExportArchiveKey,
|
|
28
|
-
buildExtractedPrefix,
|
|
29
|
-
buildExtractedSkippedKey,
|
|
30
|
-
buildExtractedTextKey,
|
|
31
27
|
buildMessageBodyKey,
|
|
32
28
|
buildOutboxAttachmentKey,
|
|
33
29
|
buildOutboxAttachmentPrefix,
|
|
@@ -201,134 +197,6 @@ export const createS3StorageService = (
|
|
|
201
197
|
return response.ContentEncoding === "gzip" ? gunzipSync(buffer) : buffer;
|
|
202
198
|
};
|
|
203
199
|
|
|
204
|
-
const storeExtractedText: StorageService["storeExtractedText"] = (params) => {
|
|
205
|
-
const { accountConfigId, accountId, messageId, partPath, text } = params;
|
|
206
|
-
return storeInternal({
|
|
207
|
-
key: buildExtractedTextKey(
|
|
208
|
-
accountConfigId,
|
|
209
|
-
accountId,
|
|
210
|
-
messageId,
|
|
211
|
-
partPath,
|
|
212
|
-
),
|
|
213
|
-
content: Buffer.from(text, "utf8"),
|
|
214
|
-
contentType: "text/plain; charset=utf-8",
|
|
215
|
-
});
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
const storeExtractedSkipped: StorageService["storeExtractedSkipped"] = (
|
|
219
|
-
params,
|
|
220
|
-
) => {
|
|
221
|
-
const { accountConfigId, accountId, messageId, partPath, marker } = params;
|
|
222
|
-
return storeInternal({
|
|
223
|
-
key: buildExtractedSkippedKey(
|
|
224
|
-
accountConfigId,
|
|
225
|
-
accountId,
|
|
226
|
-
messageId,
|
|
227
|
-
partPath,
|
|
228
|
-
),
|
|
229
|
-
content: Buffer.from(JSON.stringify(marker), "utf8"),
|
|
230
|
-
contentType: "application/json",
|
|
231
|
-
compress: false,
|
|
232
|
-
});
|
|
233
|
-
};
|
|
234
|
-
|
|
235
|
-
const extractedResultExists: StorageService["extractedResultExists"] = async (
|
|
236
|
-
accountConfigId,
|
|
237
|
-
accountId,
|
|
238
|
-
messageId,
|
|
239
|
-
partPath,
|
|
240
|
-
) => {
|
|
241
|
-
const textKey = buildExtractedTextKey(
|
|
242
|
-
accountConfigId,
|
|
243
|
-
accountId,
|
|
244
|
-
messageId,
|
|
245
|
-
partPath,
|
|
246
|
-
);
|
|
247
|
-
const skippedKey = buildExtractedSkippedKey(
|
|
248
|
-
accountConfigId,
|
|
249
|
-
accountId,
|
|
250
|
-
messageId,
|
|
251
|
-
partPath,
|
|
252
|
-
);
|
|
253
|
-
|
|
254
|
-
const headExists = (key: string): Promise<boolean> =>
|
|
255
|
-
client
|
|
256
|
-
.send(new HeadObjectCommand({ Bucket: bucketName, Key: key }))
|
|
257
|
-
.then(() => true)
|
|
258
|
-
.catch(() => false);
|
|
259
|
-
|
|
260
|
-
const [textExists, skippedExists] = await Promise.all([
|
|
261
|
-
headExists(textKey),
|
|
262
|
-
headExists(skippedKey),
|
|
263
|
-
]);
|
|
264
|
-
return textExists || skippedExists;
|
|
265
|
-
};
|
|
266
|
-
|
|
267
|
-
const retrieveExtractedText: StorageService["retrieveExtractedText"] = async (
|
|
268
|
-
accountConfigId,
|
|
269
|
-
accountId,
|
|
270
|
-
messageId,
|
|
271
|
-
partPath,
|
|
272
|
-
) => {
|
|
273
|
-
const key = buildExtractedTextKey(
|
|
274
|
-
accountConfigId,
|
|
275
|
-
accountId,
|
|
276
|
-
messageId,
|
|
277
|
-
partPath,
|
|
278
|
-
);
|
|
279
|
-
|
|
280
|
-
const response = await client
|
|
281
|
-
.send(new GetObjectCommand({ Bucket: bucketName, Key: key }))
|
|
282
|
-
.catch((error: unknown) => {
|
|
283
|
-
if (isStorageNotFoundError(error)) return null;
|
|
284
|
-
throw error;
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
if (!response) return null;
|
|
288
|
-
if (!response.Body) {
|
|
289
|
-
throw new Error(`Empty response body for extracted text: ${key}`);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
const body = await response.Body.transformToByteArray();
|
|
293
|
-
const buffer = Buffer.from(body);
|
|
294
|
-
const decoded =
|
|
295
|
-
response.ContentEncoding === "gzip" ? gunzipSync(buffer) : buffer;
|
|
296
|
-
return decoded.toString("utf8");
|
|
297
|
-
};
|
|
298
|
-
|
|
299
|
-
const listExtractedTexts: StorageService["listExtractedTexts"] = async (
|
|
300
|
-
accountConfigId,
|
|
301
|
-
accountId,
|
|
302
|
-
messageId,
|
|
303
|
-
) => {
|
|
304
|
-
const prefix = buildExtractedPrefix(accountConfigId, accountId, messageId);
|
|
305
|
-
const suffix = ".txt.gz";
|
|
306
|
-
const items: ExtractedTextListItem[] = [];
|
|
307
|
-
let continuationToken: string | undefined;
|
|
308
|
-
|
|
309
|
-
do {
|
|
310
|
-
const response = await client.send(
|
|
311
|
-
new ListObjectsV2Command({
|
|
312
|
-
Bucket: bucketName,
|
|
313
|
-
Prefix: prefix,
|
|
314
|
-
ContinuationToken: continuationToken,
|
|
315
|
-
}),
|
|
316
|
-
);
|
|
317
|
-
for (const object of response.Contents ?? []) {
|
|
318
|
-
if (!object.Key || !object.Key.endsWith(suffix)) continue;
|
|
319
|
-
items.push({
|
|
320
|
-
partPath: object.Key.slice(prefix.length, -suffix.length),
|
|
321
|
-
key: object.Key,
|
|
322
|
-
});
|
|
323
|
-
}
|
|
324
|
-
continuationToken = response.IsTruncated
|
|
325
|
-
? response.NextContinuationToken
|
|
326
|
-
: undefined;
|
|
327
|
-
} while (continuationToken);
|
|
328
|
-
|
|
329
|
-
return items;
|
|
330
|
-
};
|
|
331
|
-
|
|
332
200
|
const storeOutboxAttachment: StorageService["storeOutboxAttachment"] = (
|
|
333
201
|
params,
|
|
334
202
|
) => {
|
|
@@ -467,6 +335,28 @@ export const createS3StorageService = (
|
|
|
467
335
|
return { sizeBytes: head.ContentLength ?? 0 };
|
|
468
336
|
};
|
|
469
337
|
|
|
338
|
+
const retrieveOutboxAttachment: StorageService["retrieveOutboxAttachment"] =
|
|
339
|
+
async (accountConfigId, accountId, outboxMessageId, outboxAttachmentId) => {
|
|
340
|
+
const key = buildOutboxAttachmentKey(
|
|
341
|
+
accountConfigId,
|
|
342
|
+
accountId,
|
|
343
|
+
outboxMessageId,
|
|
344
|
+
outboxAttachmentId,
|
|
345
|
+
);
|
|
346
|
+
const response = await client
|
|
347
|
+
.send(new GetObjectCommand({ Bucket: bucketName, Key: key }))
|
|
348
|
+
.catch((error: unknown) => {
|
|
349
|
+
if (isStorageNotFoundError(error)) return null;
|
|
350
|
+
throw error;
|
|
351
|
+
});
|
|
352
|
+
if (!response) return null;
|
|
353
|
+
if (!response.Body) {
|
|
354
|
+
throw new Error(`Empty response body for outbox attachment: ${key}`);
|
|
355
|
+
}
|
|
356
|
+
const bytes = await response.Body.transformToByteArray();
|
|
357
|
+
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
358
|
+
};
|
|
359
|
+
|
|
470
360
|
// The browser PUTs straight to the bucket and these bytes never touch the
|
|
471
361
|
// API. `ContentLength` on the command hoists `content-length` into
|
|
472
362
|
// X-Amz-SignedHeaders, so S3 recomputes the signature over the length it was
|
|
@@ -718,15 +608,11 @@ export const createS3StorageService = (
|
|
|
718
608
|
deleteOutboxAttachments,
|
|
719
609
|
deleteOutboxAttachment,
|
|
720
610
|
statOutboxAttachment,
|
|
611
|
+
retrieveOutboxAttachment,
|
|
721
612
|
createOutboxAttachmentUploadUrl,
|
|
722
613
|
storeDeduplicated,
|
|
723
614
|
storeParsedBody,
|
|
724
615
|
retrieveParsedBody,
|
|
725
|
-
storeExtractedText,
|
|
726
|
-
storeExtractedSkipped,
|
|
727
|
-
extractedResultExists,
|
|
728
|
-
retrieveExtractedText,
|
|
729
|
-
listExtractedTexts,
|
|
730
616
|
retrieveMessageBody,
|
|
731
617
|
retrieveMessageBodyStream,
|
|
732
618
|
retrieve,
|
package/src/index.ts
CHANGED
|
@@ -20,9 +20,6 @@ export {
|
|
|
20
20
|
buildBodyPartKey,
|
|
21
21
|
buildDeduplicatedKey,
|
|
22
22
|
buildExportArchiveKey,
|
|
23
|
-
buildExtractedPrefix,
|
|
24
|
-
buildExtractedSkippedKey,
|
|
25
|
-
buildExtractedTextKey,
|
|
26
23
|
buildMessageBodyKey,
|
|
27
24
|
buildOutboxAttachmentKey,
|
|
28
25
|
buildOutboxAttachmentPrefix,
|
|
@@ -31,8 +28,6 @@ export {
|
|
|
31
28
|
type CreateOutboxAttachmentUploadUrlParams,
|
|
32
29
|
computeChecksum,
|
|
33
30
|
createMockStorageService,
|
|
34
|
-
type ExtractedTextListItem,
|
|
35
|
-
type ExtractedTextMarker,
|
|
36
31
|
isStorageNotFoundError,
|
|
37
32
|
type OutboxAttachmentListItem,
|
|
38
33
|
type OutboxAttachmentUploadTarget,
|
|
@@ -47,8 +42,6 @@ export {
|
|
|
47
42
|
type StorageTypeValue,
|
|
48
43
|
type StoreBodyPartParams,
|
|
49
44
|
type StoreDeduplicatedParams,
|
|
50
|
-
type StoreExtractedSkippedParams,
|
|
51
|
-
type StoreExtractedTextParams,
|
|
52
45
|
type StoreMessageBodyParams,
|
|
53
46
|
type StoreOutboxAttachmentParams,
|
|
54
47
|
type StoreParsedBodyParams,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import { createFilesystemStorageService } from "./backends/filesystem.js";
|
|
7
|
+
import { createMockStorageService, type StorageService } from "./storage.js";
|
|
8
|
+
|
|
9
|
+
const OWNER = {
|
|
10
|
+
accountConfigId: "cfg-1",
|
|
11
|
+
accountId: "acc-1",
|
|
12
|
+
outboxMessageId: "ob-1",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const readsBackWhatWasStored = async (storage: StorageService) => {
|
|
16
|
+
const content = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x00, 0xff]);
|
|
17
|
+
await storage.storeOutboxAttachment({
|
|
18
|
+
...OWNER,
|
|
19
|
+
outboxAttachmentId: "att-1",
|
|
20
|
+
content,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
assert.deepEqual(
|
|
24
|
+
await storage.retrieveOutboxAttachment(
|
|
25
|
+
OWNER.accountConfigId,
|
|
26
|
+
OWNER.accountId,
|
|
27
|
+
OWNER.outboxMessageId,
|
|
28
|
+
"att-1",
|
|
29
|
+
),
|
|
30
|
+
content,
|
|
31
|
+
);
|
|
32
|
+
assert.equal(
|
|
33
|
+
await storage.retrieveOutboxAttachment(
|
|
34
|
+
OWNER.accountConfigId,
|
|
35
|
+
OWNER.accountId,
|
|
36
|
+
OWNER.outboxMessageId,
|
|
37
|
+
"att-missing",
|
|
38
|
+
),
|
|
39
|
+
null,
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
describe("retrieveOutboxAttachment", () => {
|
|
44
|
+
it("reads back the exact bytes on the filesystem backend, and null for none", async () => {
|
|
45
|
+
const basePath = await mkdtemp(join(tmpdir(), "remit-outbox-retrieve-"));
|
|
46
|
+
try {
|
|
47
|
+
await readsBackWhatWasStored(createFilesystemStorageService(basePath));
|
|
48
|
+
} finally {
|
|
49
|
+
await rm(basePath, { recursive: true, force: true });
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("reads back the exact bytes on the mock backend, and null for none", async () => {
|
|
54
|
+
await readsBackWhatWasStored(createMockStorageService());
|
|
55
|
+
});
|
|
56
|
+
});
|
package/src/storage.test.ts
CHANGED
|
@@ -10,9 +10,6 @@ import { createFilesystemStorageService } from "./backends/filesystem.js";
|
|
|
10
10
|
import {
|
|
11
11
|
buildBodyPartKey,
|
|
12
12
|
buildDeduplicatedKey,
|
|
13
|
-
buildExtractedPrefix,
|
|
14
|
-
buildExtractedSkippedKey,
|
|
15
|
-
buildExtractedTextKey,
|
|
16
13
|
buildMessageBodyKey,
|
|
17
14
|
buildOutboxAttachmentKey,
|
|
18
15
|
buildOutboxAttachmentPrefix,
|
|
@@ -120,38 +117,6 @@ describe("path builders", () => {
|
|
|
120
117
|
assert.strictEqual(segments[1], "CFG");
|
|
121
118
|
assert.strictEqual(segments[2], "ACC");
|
|
122
119
|
});
|
|
123
|
-
|
|
124
|
-
test("buildExtractedTextKey nests accountId under accountConfigId and suffixes .txt.gz", () => {
|
|
125
|
-
const key = buildExtractedTextKey("cfg1", "acc123", "msg456", "1.2");
|
|
126
|
-
assert.strictEqual(
|
|
127
|
-
key,
|
|
128
|
-
"accounts/cfg1/acc123/messages/msg456/extracted/1.2.txt.gz",
|
|
129
|
-
);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
test("buildExtractedSkippedKey nests accountId under accountConfigId and suffixes .skipped.json", () => {
|
|
133
|
-
const key = buildExtractedSkippedKey("cfg1", "acc123", "msg456", "1.2");
|
|
134
|
-
assert.strictEqual(
|
|
135
|
-
key,
|
|
136
|
-
"accounts/cfg1/acc123/messages/msg456/extracted/1.2.skipped.json",
|
|
137
|
-
);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
test("buildExtractedTextKey and buildExtractedSkippedKey share the same prefix", () => {
|
|
141
|
-
const prefix = buildExtractedPrefix("cfg1", "acc123", "msg456");
|
|
142
|
-
assert.strictEqual(
|
|
143
|
-
prefix,
|
|
144
|
-
"accounts/cfg1/acc123/messages/msg456/extracted/",
|
|
145
|
-
);
|
|
146
|
-
assert.ok(
|
|
147
|
-
buildExtractedTextKey("cfg1", "acc123", "msg456", "1").startsWith(prefix),
|
|
148
|
-
);
|
|
149
|
-
assert.ok(
|
|
150
|
-
buildExtractedSkippedKey("cfg1", "acc123", "msg456", "1").startsWith(
|
|
151
|
-
prefix,
|
|
152
|
-
),
|
|
153
|
-
);
|
|
154
|
-
});
|
|
155
120
|
});
|
|
156
121
|
|
|
157
122
|
describe("parseContentStorageKey", () => {
|
|
@@ -374,115 +339,6 @@ describe("createMockStorageService", () => {
|
|
|
374
339
|
);
|
|
375
340
|
assert.strictEqual(miss, null);
|
|
376
341
|
});
|
|
377
|
-
|
|
378
|
-
test("storeExtractedText / retrieveExtractedText round-trip", async () => {
|
|
379
|
-
const storage = createMockStorageService();
|
|
380
|
-
|
|
381
|
-
const ref = await storage.storeExtractedText({
|
|
382
|
-
accountConfigId: "cfg1",
|
|
383
|
-
accountId: "acc123",
|
|
384
|
-
messageId: "msg456",
|
|
385
|
-
partPath: "1.2",
|
|
386
|
-
text: "extracted attachment text",
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
assert.strictEqual(
|
|
390
|
-
ref.storageKey,
|
|
391
|
-
"accounts/cfg1/acc123/messages/msg456/extracted/1.2.txt.gz",
|
|
392
|
-
);
|
|
393
|
-
|
|
394
|
-
const retrieved = await storage.retrieveExtractedText(
|
|
395
|
-
"cfg1",
|
|
396
|
-
"acc123",
|
|
397
|
-
"msg456",
|
|
398
|
-
"1.2",
|
|
399
|
-
);
|
|
400
|
-
assert.strictEqual(retrieved, "extracted attachment text");
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
test("retrieveExtractedText returns null on miss", async () => {
|
|
404
|
-
const storage = createMockStorageService();
|
|
405
|
-
const result = await storage.retrieveExtractedText(
|
|
406
|
-
"cfg1",
|
|
407
|
-
"acc123",
|
|
408
|
-
"msg456",
|
|
409
|
-
"nope",
|
|
410
|
-
);
|
|
411
|
-
assert.strictEqual(result, null);
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
test("extractedResultExists is true after either the text artifact or the skip marker is stored", async () => {
|
|
415
|
-
const storage = createMockStorageService();
|
|
416
|
-
|
|
417
|
-
assert.strictEqual(
|
|
418
|
-
await storage.extractedResultExists("cfg1", "acc123", "msg456", "1"),
|
|
419
|
-
false,
|
|
420
|
-
);
|
|
421
|
-
|
|
422
|
-
await storage.storeExtractedText({
|
|
423
|
-
accountConfigId: "cfg1",
|
|
424
|
-
accountId: "acc123",
|
|
425
|
-
messageId: "msg456",
|
|
426
|
-
partPath: "1",
|
|
427
|
-
text: "hi",
|
|
428
|
-
});
|
|
429
|
-
assert.strictEqual(
|
|
430
|
-
await storage.extractedResultExists("cfg1", "acc123", "msg456", "1"),
|
|
431
|
-
true,
|
|
432
|
-
);
|
|
433
|
-
|
|
434
|
-
await storage.storeExtractedSkipped({
|
|
435
|
-
accountConfigId: "cfg1",
|
|
436
|
-
accountId: "acc123",
|
|
437
|
-
messageId: "msg456",
|
|
438
|
-
partPath: "2",
|
|
439
|
-
marker: { status: "skipped", reason: "type-not-allowed" },
|
|
440
|
-
});
|
|
441
|
-
assert.strictEqual(
|
|
442
|
-
await storage.extractedResultExists("cfg1", "acc123", "msg456", "2"),
|
|
443
|
-
true,
|
|
444
|
-
);
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
test("listExtractedTexts returns only .txt.gz artifacts, not skip markers", async () => {
|
|
448
|
-
const storage = createMockStorageService();
|
|
449
|
-
|
|
450
|
-
await storage.storeExtractedText({
|
|
451
|
-
accountConfigId: "cfg1",
|
|
452
|
-
accountId: "acc123",
|
|
453
|
-
messageId: "msg456",
|
|
454
|
-
partPath: "1",
|
|
455
|
-
text: "first",
|
|
456
|
-
});
|
|
457
|
-
await storage.storeExtractedText({
|
|
458
|
-
accountConfigId: "cfg1",
|
|
459
|
-
accountId: "acc123",
|
|
460
|
-
messageId: "msg456",
|
|
461
|
-
partPath: "2.1",
|
|
462
|
-
text: "second",
|
|
463
|
-
});
|
|
464
|
-
await storage.storeExtractedSkipped({
|
|
465
|
-
accountConfigId: "cfg1",
|
|
466
|
-
accountId: "acc123",
|
|
467
|
-
messageId: "msg456",
|
|
468
|
-
partPath: "3",
|
|
469
|
-
marker: { status: "failed", reason: "pdf: corrupt" },
|
|
470
|
-
});
|
|
471
|
-
// A different message must not leak into the list.
|
|
472
|
-
await storage.storeExtractedText({
|
|
473
|
-
accountConfigId: "cfg1",
|
|
474
|
-
accountId: "acc123",
|
|
475
|
-
messageId: "other-msg",
|
|
476
|
-
partPath: "1",
|
|
477
|
-
text: "unrelated",
|
|
478
|
-
});
|
|
479
|
-
|
|
480
|
-
const items = await storage.listExtractedTexts("cfg1", "acc123", "msg456");
|
|
481
|
-
assert.deepStrictEqual(items.map((i) => i.partPath).sort(), ["1", "2.1"]);
|
|
482
|
-
for (const item of items) {
|
|
483
|
-
assert.ok(item.key.endsWith(".txt.gz"));
|
|
484
|
-
}
|
|
485
|
-
});
|
|
486
342
|
});
|
|
487
343
|
|
|
488
344
|
describe("createFilesystemStorageService", () => {
|
|
@@ -670,102 +526,6 @@ describe("createFilesystemStorageService", () => {
|
|
|
670
526
|
assert.strictEqual(miss, null);
|
|
671
527
|
});
|
|
672
528
|
|
|
673
|
-
test("storeExtractedText / retrieveExtractedText round-trip with gzip", async () => {
|
|
674
|
-
const storage = createFilesystemStorageService(testBasePath);
|
|
675
|
-
|
|
676
|
-
const ref = await storage.storeExtractedText({
|
|
677
|
-
accountConfigId: "cfg1",
|
|
678
|
-
accountId: "acc123",
|
|
679
|
-
messageId: "msg-extracted-fs",
|
|
680
|
-
partPath: "1",
|
|
681
|
-
text: "fs extracted text",
|
|
682
|
-
});
|
|
683
|
-
|
|
684
|
-
assert.strictEqual(
|
|
685
|
-
ref.storageKey,
|
|
686
|
-
"accounts/cfg1/acc123/messages/msg-extracted-fs/extracted/1.txt.gz",
|
|
687
|
-
);
|
|
688
|
-
assert.strictEqual(ref.contentEncoding, ContentEncoding.Gzip);
|
|
689
|
-
|
|
690
|
-
const retrieved = await storage.retrieveExtractedText(
|
|
691
|
-
"cfg1",
|
|
692
|
-
"acc123",
|
|
693
|
-
"msg-extracted-fs",
|
|
694
|
-
"1",
|
|
695
|
-
);
|
|
696
|
-
assert.strictEqual(retrieved, "fs extracted text");
|
|
697
|
-
});
|
|
698
|
-
|
|
699
|
-
test("storeExtractedSkipped writes an uncompressed JSON marker that satisfies extractedResultExists", async () => {
|
|
700
|
-
const storage = createFilesystemStorageService(testBasePath);
|
|
701
|
-
|
|
702
|
-
assert.strictEqual(
|
|
703
|
-
await storage.extractedResultExists("cfg1", "acc123", "msg-skip-fs", "1"),
|
|
704
|
-
false,
|
|
705
|
-
);
|
|
706
|
-
|
|
707
|
-
const ref = await storage.storeExtractedSkipped({
|
|
708
|
-
accountConfigId: "cfg1",
|
|
709
|
-
accountId: "acc123",
|
|
710
|
-
messageId: "msg-skip-fs",
|
|
711
|
-
partPath: "1",
|
|
712
|
-
marker: { status: "skipped", reason: "too-large" },
|
|
713
|
-
});
|
|
714
|
-
|
|
715
|
-
assert.strictEqual(
|
|
716
|
-
ref.storageKey,
|
|
717
|
-
"accounts/cfg1/acc123/messages/msg-skip-fs/extracted/1.skipped.json",
|
|
718
|
-
);
|
|
719
|
-
assert.strictEqual(ref.contentEncoding, ContentEncoding.None);
|
|
720
|
-
assert.strictEqual(
|
|
721
|
-
await storage.extractedResultExists("cfg1", "acc123", "msg-skip-fs", "1"),
|
|
722
|
-
true,
|
|
723
|
-
);
|
|
724
|
-
});
|
|
725
|
-
|
|
726
|
-
test("listExtractedTexts lists only .txt.gz artifacts for the given message", async () => {
|
|
727
|
-
const storage = createFilesystemStorageService(testBasePath);
|
|
728
|
-
|
|
729
|
-
await storage.storeExtractedText({
|
|
730
|
-
accountConfigId: "cfg1",
|
|
731
|
-
accountId: "acc123",
|
|
732
|
-
messageId: "msg-list-fs",
|
|
733
|
-
partPath: "1",
|
|
734
|
-
text: "one",
|
|
735
|
-
});
|
|
736
|
-
await storage.storeExtractedText({
|
|
737
|
-
accountConfigId: "cfg1",
|
|
738
|
-
accountId: "acc123",
|
|
739
|
-
messageId: "msg-list-fs",
|
|
740
|
-
partPath: "2.1",
|
|
741
|
-
text: "two",
|
|
742
|
-
});
|
|
743
|
-
await storage.storeExtractedSkipped({
|
|
744
|
-
accountConfigId: "cfg1",
|
|
745
|
-
accountId: "acc123",
|
|
746
|
-
messageId: "msg-list-fs",
|
|
747
|
-
partPath: "3",
|
|
748
|
-
marker: { status: "failed", reason: "doc: corrupt" },
|
|
749
|
-
});
|
|
750
|
-
|
|
751
|
-
const items = await storage.listExtractedTexts(
|
|
752
|
-
"cfg1",
|
|
753
|
-
"acc123",
|
|
754
|
-
"msg-list-fs",
|
|
755
|
-
);
|
|
756
|
-
assert.deepStrictEqual(items.map((i) => i.partPath).sort(), ["1", "2.1"]);
|
|
757
|
-
});
|
|
758
|
-
|
|
759
|
-
test("listExtractedTexts returns an empty array when no extraction has happened yet", async () => {
|
|
760
|
-
const storage = createFilesystemStorageService(testBasePath);
|
|
761
|
-
const items = await storage.listExtractedTexts(
|
|
762
|
-
"cfg1",
|
|
763
|
-
"acc123",
|
|
764
|
-
"msg-never-scanned",
|
|
765
|
-
);
|
|
766
|
-
assert.deepStrictEqual(items, []);
|
|
767
|
-
});
|
|
768
|
-
|
|
769
529
|
test("storeOutboxAttachment writes the bytes uncompressed so the size on disk is the size the cap counts", async () => {
|
|
770
530
|
const storage = createFilesystemStorageService(testBasePath);
|
|
771
531
|
// Highly compressible: gzip would make the stored size disagree with the
|
package/src/storage.ts
CHANGED
|
@@ -128,41 +128,6 @@ export interface StoreParsedBodyParams {
|
|
|
128
128
|
parsed: ParsedBody;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
/** Parameters for storing extracted attachment text (attachment-scan worker, #450) */
|
|
132
|
-
export interface StoreExtractedTextParams {
|
|
133
|
-
accountConfigId: string;
|
|
134
|
-
accountId: string;
|
|
135
|
-
messageId: string;
|
|
136
|
-
partPath: string;
|
|
137
|
-
text: string;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Marker written in place of extracted text when extraction is skipped or
|
|
142
|
-
* fails, so the idempotency check terminates retries on a poison or
|
|
143
|
-
* unsupported attachment instead of re-running extraction on every
|
|
144
|
-
* redelivery.
|
|
145
|
-
*/
|
|
146
|
-
export interface ExtractedTextMarker {
|
|
147
|
-
status: "skipped" | "failed";
|
|
148
|
-
reason: string;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** Parameters for storing an extraction skip/failure marker */
|
|
152
|
-
export interface StoreExtractedSkippedParams {
|
|
153
|
-
accountConfigId: string;
|
|
154
|
-
accountId: string;
|
|
155
|
-
messageId: string;
|
|
156
|
-
partPath: string;
|
|
157
|
-
marker: ExtractedTextMarker;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** One extracted-text artifact found under a message's `extracted/` prefix */
|
|
161
|
-
export interface ExtractedTextListItem {
|
|
162
|
-
partPath: string;
|
|
163
|
-
key: string;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
131
|
export interface StorageService {
|
|
167
132
|
/** Store a message body (raw RFC822 content) */
|
|
168
133
|
storeMessageBody(params: StoreMessageBodyParams): Promise<StorageReference>;
|
|
@@ -266,6 +231,17 @@ export interface StorageService {
|
|
|
266
231
|
outboxAttachmentId: string,
|
|
267
232
|
): Promise<{ sizeBytes: number } | null>;
|
|
268
233
|
|
|
234
|
+
/**
|
|
235
|
+
* The bytes of one attachment, for building the MIME part it goes out as.
|
|
236
|
+
* Null when nothing is stored under the key.
|
|
237
|
+
*/
|
|
238
|
+
retrieveOutboxAttachment(
|
|
239
|
+
accountConfigId: string,
|
|
240
|
+
accountId: string,
|
|
241
|
+
outboxMessageId: string,
|
|
242
|
+
outboxAttachmentId: string,
|
|
243
|
+
): Promise<Buffer | null>;
|
|
244
|
+
|
|
269
245
|
/**
|
|
270
246
|
* Mint somewhere to PUT one file. The backend decides what that is — block
|
|
271
247
|
* storage direct, or this deployment's upload route — and the caller never
|
|
@@ -290,48 +266,6 @@ export interface StorageService {
|
|
|
290
266
|
messageId: string,
|
|
291
267
|
): Promise<ParsedBody | null>;
|
|
292
268
|
|
|
293
|
-
/** Store extracted attachment text (attachment-scan worker, #450) */
|
|
294
|
-
storeExtractedText(
|
|
295
|
-
params: StoreExtractedTextParams,
|
|
296
|
-
): Promise<StorageReference>;
|
|
297
|
-
|
|
298
|
-
/** Store a skip/failure marker in place of extracted text */
|
|
299
|
-
storeExtractedSkipped(
|
|
300
|
-
params: StoreExtractedSkippedParams,
|
|
301
|
-
): Promise<StorageReference>;
|
|
302
|
-
|
|
303
|
-
/**
|
|
304
|
-
* Whether an extraction result — either the text artifact or the
|
|
305
|
-
* skip/failure marker — already exists for this part. Makes extraction
|
|
306
|
-
* idempotent: a redelivered SQS message short-circuits instead of
|
|
307
|
-
* re-running extraction (#450).
|
|
308
|
-
*/
|
|
309
|
-
extractedResultExists(
|
|
310
|
-
accountConfigId: string,
|
|
311
|
-
accountId: string,
|
|
312
|
-
messageId: string,
|
|
313
|
-
partPath: string,
|
|
314
|
-
): Promise<boolean>;
|
|
315
|
-
|
|
316
|
-
/** Retrieve previously extracted text; returns null on NoSuchKey */
|
|
317
|
-
retrieveExtractedText(
|
|
318
|
-
accountConfigId: string,
|
|
319
|
-
accountId: string,
|
|
320
|
-
messageId: string,
|
|
321
|
-
partPath: string,
|
|
322
|
-
): Promise<string | null>;
|
|
323
|
-
|
|
324
|
-
/**
|
|
325
|
-
* List every extracted-text artifact stored for a message — the
|
|
326
|
-
* `.skipped.json` markers are excluded. Used by the search-index worker
|
|
327
|
-
* (#452) to fold attachment text into a message's vectors.
|
|
328
|
-
*/
|
|
329
|
-
listExtractedTexts(
|
|
330
|
-
accountConfigId: string,
|
|
331
|
-
accountId: string,
|
|
332
|
-
messageId: string,
|
|
333
|
-
): Promise<ExtractedTextListItem[]>;
|
|
334
|
-
|
|
335
269
|
/** Retrieve a raw message body (RFC822 .eml) by account/message id; returns null on NoSuchKey */
|
|
336
270
|
retrieveMessageBody(
|
|
337
271
|
accountConfigId: string,
|
|
@@ -409,29 +343,6 @@ export const buildBodyPartKey = (
|
|
|
409
343
|
): string =>
|
|
410
344
|
`accounts/${accountConfigId}/${accountId}/messages/${messageId}/parts/${partPath}`;
|
|
411
345
|
|
|
412
|
-
export const buildExtractedTextKey = (
|
|
413
|
-
accountConfigId: string,
|
|
414
|
-
accountId: string,
|
|
415
|
-
messageId: string,
|
|
416
|
-
partPath: string,
|
|
417
|
-
): string =>
|
|
418
|
-
`${buildExtractedPrefix(accountConfigId, accountId, messageId)}${partPath}.txt.gz`;
|
|
419
|
-
|
|
420
|
-
export const buildExtractedSkippedKey = (
|
|
421
|
-
accountConfigId: string,
|
|
422
|
-
accountId: string,
|
|
423
|
-
messageId: string,
|
|
424
|
-
partPath: string,
|
|
425
|
-
): string =>
|
|
426
|
-
`${buildExtractedPrefix(accountConfigId, accountId, messageId)}${partPath}.skipped.json`;
|
|
427
|
-
|
|
428
|
-
export const buildExtractedPrefix = (
|
|
429
|
-
accountConfigId: string,
|
|
430
|
-
accountId: string,
|
|
431
|
-
messageId: string,
|
|
432
|
-
): string =>
|
|
433
|
-
`accounts/${accountConfigId}/${accountId}/messages/${messageId}/extracted/`;
|
|
434
|
-
|
|
435
346
|
export const buildOutboxAttachmentPrefix = (
|
|
436
347
|
accountConfigId: string,
|
|
437
348
|
accountId: string,
|
|
@@ -650,6 +561,12 @@ export const createMockStorageService = (): StorageService => {
|
|
|
650
561
|
return content ? { sizeBytes: content.length } : null;
|
|
651
562
|
};
|
|
652
563
|
|
|
564
|
+
const retrieveOutboxAttachment: StorageService["retrieveOutboxAttachment"] =
|
|
565
|
+
async (accountConfigId, accountId, outboxMessageId, outboxAttachmentId) =>
|
|
566
|
+
storage.get(
|
|
567
|
+
`mock://${buildOutboxAttachmentKey(accountConfigId, accountId, outboxMessageId, outboxAttachmentId)}`,
|
|
568
|
+
) ?? null;
|
|
569
|
+
|
|
653
570
|
const createOutboxAttachmentUploadUrl: StorageService["createOutboxAttachmentUploadUrl"] =
|
|
654
571
|
async (params) => ({
|
|
655
572
|
uploadUrl: `mock://upload/${buildOutboxAttachmentKey(params.accountConfigId, params.accountId, params.outboxMessageId, params.outboxAttachmentId)}?max=${params.sizeBytes}&exp=${params.expiresAt}`,
|
|
@@ -704,68 +621,6 @@ export const createMockStorageService = (): StorageService => {
|
|
|
704
621
|
return storage.get(uri) ?? null;
|
|
705
622
|
};
|
|
706
623
|
|
|
707
|
-
const storeExtractedText: StorageService["storeExtractedText"] = async (
|
|
708
|
-
params,
|
|
709
|
-
) => {
|
|
710
|
-
const { accountConfigId, accountId, messageId, partPath, text } = params;
|
|
711
|
-
return storeInternal(
|
|
712
|
-
buildExtractedTextKey(accountConfigId, accountId, messageId, partPath),
|
|
713
|
-
Buffer.from(text, "utf8"),
|
|
714
|
-
);
|
|
715
|
-
};
|
|
716
|
-
|
|
717
|
-
const storeExtractedSkipped: StorageService["storeExtractedSkipped"] = async (
|
|
718
|
-
params,
|
|
719
|
-
) => {
|
|
720
|
-
const { accountConfigId, accountId, messageId, partPath, marker } = params;
|
|
721
|
-
return storeInternal(
|
|
722
|
-
buildExtractedSkippedKey(accountConfigId, accountId, messageId, partPath),
|
|
723
|
-
Buffer.from(JSON.stringify(marker), "utf8"),
|
|
724
|
-
);
|
|
725
|
-
};
|
|
726
|
-
|
|
727
|
-
const extractedResultExists: StorageService["extractedResultExists"] = async (
|
|
728
|
-
accountConfigId,
|
|
729
|
-
accountId,
|
|
730
|
-
messageId,
|
|
731
|
-
partPath,
|
|
732
|
-
) => {
|
|
733
|
-
const textUri = `mock://${buildExtractedTextKey(accountConfigId, accountId, messageId, partPath)}`;
|
|
734
|
-
const skippedUri = `mock://${buildExtractedSkippedKey(accountConfigId, accountId, messageId, partPath)}`;
|
|
735
|
-
return storage.has(textUri) || storage.has(skippedUri);
|
|
736
|
-
};
|
|
737
|
-
|
|
738
|
-
const retrieveExtractedText: StorageService["retrieveExtractedText"] = async (
|
|
739
|
-
accountConfigId,
|
|
740
|
-
accountId,
|
|
741
|
-
messageId,
|
|
742
|
-
partPath,
|
|
743
|
-
) => {
|
|
744
|
-
const uri = `mock://${buildExtractedTextKey(accountConfigId, accountId, messageId, partPath)}`;
|
|
745
|
-
const content = storage.get(uri);
|
|
746
|
-
if (!content) return null;
|
|
747
|
-
return content.toString("utf8");
|
|
748
|
-
};
|
|
749
|
-
|
|
750
|
-
const listExtractedTexts: StorageService["listExtractedTexts"] = async (
|
|
751
|
-
accountConfigId,
|
|
752
|
-
accountId,
|
|
753
|
-
messageId,
|
|
754
|
-
) => {
|
|
755
|
-
const prefix = `mock://${buildExtractedPrefix(accountConfigId, accountId, messageId)}`;
|
|
756
|
-
const suffix = ".txt.gz";
|
|
757
|
-
const items: ExtractedTextListItem[] = [];
|
|
758
|
-
for (const uri of storage.keys()) {
|
|
759
|
-
if (!uri.startsWith(prefix) || !uri.endsWith(suffix)) continue;
|
|
760
|
-
const key = uri.slice("mock://".length);
|
|
761
|
-
items.push({
|
|
762
|
-
partPath: uri.slice(prefix.length, -suffix.length),
|
|
763
|
-
key,
|
|
764
|
-
});
|
|
765
|
-
}
|
|
766
|
-
return items;
|
|
767
|
-
};
|
|
768
|
-
|
|
769
624
|
const retrieveMessageBody: StorageService["retrieveMessageBody"] = async (
|
|
770
625
|
accountConfigId,
|
|
771
626
|
accountId,
|
|
@@ -805,15 +660,11 @@ export const createMockStorageService = (): StorageService => {
|
|
|
805
660
|
deleteOutboxAttachments,
|
|
806
661
|
deleteOutboxAttachment,
|
|
807
662
|
statOutboxAttachment,
|
|
663
|
+
retrieveOutboxAttachment,
|
|
808
664
|
createOutboxAttachmentUploadUrl,
|
|
809
665
|
storeDeduplicated,
|
|
810
666
|
storeParsedBody,
|
|
811
667
|
retrieveParsedBody,
|
|
812
|
-
storeExtractedText,
|
|
813
|
-
storeExtractedSkipped,
|
|
814
|
-
extractedResultExists,
|
|
815
|
-
retrieveExtractedText,
|
|
816
|
-
listExtractedTexts,
|
|
817
668
|
retrieveMessageBody,
|
|
818
669
|
retrieveMessageBodyStream,
|
|
819
670
|
retrieve: async (uri) => {
|