@remit/storage-service 0.0.8 → 0.0.9
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 +5 -1
- package/src/backends/filesystem.ts +203 -0
- package/src/backends/s3.test.ts +144 -6
- package/src/backends/s3.ts +214 -0
- package/src/index.ts +26 -0
- package/src/outbox-sweep.test.ts +196 -0
- package/src/outbox-sweep.ts +103 -0
- package/src/outbox-upload-url.test.ts +290 -0
- package/src/outbox-upload-url.ts +78 -0
- package/src/s3.ts +17 -1
- package/src/signed-path.ts +86 -0
- package/src/storage.test.ts +136 -0
- package/src/storage.ts +259 -0
package/src/backends/s3.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { pipeline } from "node:stream/promises";
|
|
|
4
4
|
import { createGunzip, createGzip, gunzipSync, gzipSync } from "node:zlib";
|
|
5
5
|
import {
|
|
6
6
|
DeleteObjectCommand,
|
|
7
|
+
DeleteObjectsCommand,
|
|
7
8
|
GetObjectCommand,
|
|
8
9
|
HeadObjectCommand,
|
|
9
10
|
ListObjectsV2Command,
|
|
@@ -15,6 +16,7 @@ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
|
15
16
|
import { ContentEncoding, StorageType } from "@remit/domain-enums";
|
|
16
17
|
import type {
|
|
17
18
|
ExtractedTextListItem,
|
|
19
|
+
OutboxAttachmentListItem,
|
|
18
20
|
ParsedBody,
|
|
19
21
|
StorageReference,
|
|
20
22
|
StorageService,
|
|
@@ -27,6 +29,8 @@ import {
|
|
|
27
29
|
buildExtractedSkippedKey,
|
|
28
30
|
buildExtractedTextKey,
|
|
29
31
|
buildMessageBodyKey,
|
|
32
|
+
buildOutboxAttachmentKey,
|
|
33
|
+
buildOutboxAttachmentPrefix,
|
|
30
34
|
buildParsedBodyKey,
|
|
31
35
|
computeChecksum,
|
|
32
36
|
isStorageNotFoundError,
|
|
@@ -325,6 +329,209 @@ export const createS3StorageService = (
|
|
|
325
329
|
return items;
|
|
326
330
|
};
|
|
327
331
|
|
|
332
|
+
const storeOutboxAttachment: StorageService["storeOutboxAttachment"] = (
|
|
333
|
+
params,
|
|
334
|
+
) => {
|
|
335
|
+
const {
|
|
336
|
+
accountConfigId,
|
|
337
|
+
accountId,
|
|
338
|
+
outboxMessageId,
|
|
339
|
+
outboxAttachmentId,
|
|
340
|
+
content,
|
|
341
|
+
} = params;
|
|
342
|
+
// `application/octet-stream` rather than the uploader's declared type:
|
|
343
|
+
// the object is only ever read back to build a MIME part, and a stored
|
|
344
|
+
// `text/html` would be a type any future signed-URL read path could be
|
|
345
|
+
// tricked into serving from the content origin.
|
|
346
|
+
return storeInternal({
|
|
347
|
+
key: buildOutboxAttachmentKey(
|
|
348
|
+
accountConfigId,
|
|
349
|
+
accountId,
|
|
350
|
+
outboxMessageId,
|
|
351
|
+
outboxAttachmentId,
|
|
352
|
+
),
|
|
353
|
+
content,
|
|
354
|
+
contentType: "application/octet-stream",
|
|
355
|
+
compress: false,
|
|
356
|
+
});
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const listOutboxAttachments: StorageService["listOutboxAttachments"] = async (
|
|
360
|
+
accountConfigId,
|
|
361
|
+
accountId,
|
|
362
|
+
outboxMessageId,
|
|
363
|
+
) => {
|
|
364
|
+
const prefix = buildOutboxAttachmentPrefix(
|
|
365
|
+
accountConfigId,
|
|
366
|
+
accountId,
|
|
367
|
+
outboxMessageId,
|
|
368
|
+
);
|
|
369
|
+
const items: OutboxAttachmentListItem[] = [];
|
|
370
|
+
let continuationToken: string | undefined;
|
|
371
|
+
|
|
372
|
+
do {
|
|
373
|
+
const response = await client.send(
|
|
374
|
+
new ListObjectsV2Command({
|
|
375
|
+
Bucket: bucketName,
|
|
376
|
+
Prefix: prefix,
|
|
377
|
+
ContinuationToken: continuationToken,
|
|
378
|
+
}),
|
|
379
|
+
);
|
|
380
|
+
for (const object of response.Contents ?? []) {
|
|
381
|
+
if (!object.Key) continue;
|
|
382
|
+
items.push({
|
|
383
|
+
outboxAttachmentId: object.Key.slice(prefix.length),
|
|
384
|
+
key: object.Key,
|
|
385
|
+
sizeBytes: object.Size ?? 0,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
continuationToken = response.IsTruncated
|
|
389
|
+
? response.NextContinuationToken
|
|
390
|
+
: undefined;
|
|
391
|
+
} while (continuationToken);
|
|
392
|
+
|
|
393
|
+
return items;
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const listOutboxDraftsWithAttachments: StorageService["listOutboxDraftsWithAttachments"] =
|
|
397
|
+
async (accountConfigId, accountId) => {
|
|
398
|
+
const root = `accounts/${accountConfigId}/${accountId}/outbox/`;
|
|
399
|
+
const drafts = new Set<string>();
|
|
400
|
+
let continuationToken: string | undefined;
|
|
401
|
+
|
|
402
|
+
// Delimited listing: S3 answers with the directory-like prefixes rather
|
|
403
|
+
// than every object under them, so this stays one call per page of
|
|
404
|
+
// drafts instead of one per object.
|
|
405
|
+
do {
|
|
406
|
+
const response = await client.send(
|
|
407
|
+
new ListObjectsV2Command({
|
|
408
|
+
Bucket: bucketName,
|
|
409
|
+
Prefix: root,
|
|
410
|
+
Delimiter: "/",
|
|
411
|
+
ContinuationToken: continuationToken,
|
|
412
|
+
}),
|
|
413
|
+
);
|
|
414
|
+
for (const entry of response.CommonPrefixes ?? []) {
|
|
415
|
+
if (!entry.Prefix) continue;
|
|
416
|
+
drafts.add(entry.Prefix.slice(root.length).replace(/\/$/, ""));
|
|
417
|
+
}
|
|
418
|
+
continuationToken = response.IsTruncated
|
|
419
|
+
? response.NextContinuationToken
|
|
420
|
+
: undefined;
|
|
421
|
+
} while (continuationToken);
|
|
422
|
+
|
|
423
|
+
return [...drafts];
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const deleteOutboxAttachment: StorageService["deleteOutboxAttachment"] =
|
|
427
|
+
async (accountConfigId, accountId, outboxMessageId, outboxAttachmentId) => {
|
|
428
|
+
const prefix = buildOutboxAttachmentPrefix(
|
|
429
|
+
accountConfigId,
|
|
430
|
+
accountId,
|
|
431
|
+
outboxMessageId,
|
|
432
|
+
);
|
|
433
|
+
await client.send(
|
|
434
|
+
new DeleteObjectCommand({
|
|
435
|
+
Bucket: bucketName,
|
|
436
|
+
Key: `${prefix}${outboxAttachmentId}`,
|
|
437
|
+
}),
|
|
438
|
+
);
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const statOutboxAttachment: StorageService["statOutboxAttachment"] = async (
|
|
442
|
+
accountConfigId,
|
|
443
|
+
accountId,
|
|
444
|
+
outboxMessageId,
|
|
445
|
+
outboxAttachmentId,
|
|
446
|
+
) => {
|
|
447
|
+
const head = await client
|
|
448
|
+
.send(
|
|
449
|
+
new HeadObjectCommand({
|
|
450
|
+
Bucket: bucketName,
|
|
451
|
+
Key: buildOutboxAttachmentKey(
|
|
452
|
+
accountConfigId,
|
|
453
|
+
accountId,
|
|
454
|
+
outboxMessageId,
|
|
455
|
+
outboxAttachmentId,
|
|
456
|
+
),
|
|
457
|
+
}),
|
|
458
|
+
)
|
|
459
|
+
.catch((error: unknown) => {
|
|
460
|
+
if (isStorageNotFoundError(error)) return null;
|
|
461
|
+
// A HEAD on a missing key answers 404 with no NoSuchKey code, so the
|
|
462
|
+
// SDK's NotFound is the same absence.
|
|
463
|
+
if ((error as { name?: string })?.name === "NotFound") return null;
|
|
464
|
+
throw error;
|
|
465
|
+
});
|
|
466
|
+
if (!head) return null;
|
|
467
|
+
return { sizeBytes: head.ContentLength ?? 0 };
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
// The browser PUTs straight to the bucket and these bytes never touch the
|
|
471
|
+
// API. `ContentLength` on the command hoists `content-length` into
|
|
472
|
+
// X-Amz-SignedHeaders, so S3 recomputes the signature over the length it was
|
|
473
|
+
// sent: a request declaring any other size fails the signature check, and a
|
|
474
|
+
// PUT is read for exactly that many bytes. The size is bound by S3 itself,
|
|
475
|
+
// not by anything downstream trusting the client.
|
|
476
|
+
const createOutboxAttachmentUploadUrl: StorageService["createOutboxAttachmentUploadUrl"] =
|
|
477
|
+
async (params) => {
|
|
478
|
+
const expiresIn = Math.max(
|
|
479
|
+
1,
|
|
480
|
+
params.expiresAt - Math.floor(Date.now() / 1000),
|
|
481
|
+
);
|
|
482
|
+
const uploadUrl = await getSignedUrl(
|
|
483
|
+
client,
|
|
484
|
+
new PutObjectCommand({
|
|
485
|
+
Bucket: bucketName,
|
|
486
|
+
Key: buildOutboxAttachmentKey(
|
|
487
|
+
params.accountConfigId,
|
|
488
|
+
params.accountId,
|
|
489
|
+
params.outboxMessageId,
|
|
490
|
+
params.outboxAttachmentId,
|
|
491
|
+
),
|
|
492
|
+
ContentLength: params.sizeBytes,
|
|
493
|
+
}),
|
|
494
|
+
{ expiresIn },
|
|
495
|
+
);
|
|
496
|
+
return { uploadUrl };
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
const deleteOutboxAttachments: StorageService["deleteOutboxAttachments"] =
|
|
500
|
+
async (accountConfigId, accountId, outboxMessageId) => {
|
|
501
|
+
const prefix = buildOutboxAttachmentPrefix(
|
|
502
|
+
accountConfigId,
|
|
503
|
+
accountId,
|
|
504
|
+
outboxMessageId,
|
|
505
|
+
);
|
|
506
|
+
let continuationToken: string | undefined;
|
|
507
|
+
|
|
508
|
+
do {
|
|
509
|
+
const response = await client.send(
|
|
510
|
+
new ListObjectsV2Command({
|
|
511
|
+
Bucket: bucketName,
|
|
512
|
+
Prefix: prefix,
|
|
513
|
+
ContinuationToken: continuationToken,
|
|
514
|
+
}),
|
|
515
|
+
);
|
|
516
|
+
const keys = (response.Contents ?? [])
|
|
517
|
+
.map((object) => object.Key)
|
|
518
|
+
.filter((key): key is string => typeof key === "string");
|
|
519
|
+
|
|
520
|
+
if (keys.length > 0) {
|
|
521
|
+
await client.send(
|
|
522
|
+
new DeleteObjectsCommand({
|
|
523
|
+
Bucket: bucketName,
|
|
524
|
+
Delete: { Objects: keys.map((Key) => ({ Key })) },
|
|
525
|
+
}),
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
continuationToken = response.IsTruncated
|
|
530
|
+
? response.NextContinuationToken
|
|
531
|
+
: undefined;
|
|
532
|
+
} while (continuationToken);
|
|
533
|
+
};
|
|
534
|
+
|
|
328
535
|
const storeDeduplicated: StorageService["storeDeduplicated"] = (params) => {
|
|
329
536
|
const { accountConfigId, accountId, content, contentType } = params;
|
|
330
537
|
const checksumSha256 = computeChecksum(content);
|
|
@@ -505,6 +712,13 @@ export const createS3StorageService = (
|
|
|
505
712
|
storeBodyPart,
|
|
506
713
|
bodyPartExists,
|
|
507
714
|
retrieveBodyPart,
|
|
715
|
+
storeOutboxAttachment,
|
|
716
|
+
listOutboxAttachments,
|
|
717
|
+
listOutboxDraftsWithAttachments,
|
|
718
|
+
deleteOutboxAttachments,
|
|
719
|
+
deleteOutboxAttachment,
|
|
720
|
+
statOutboxAttachment,
|
|
721
|
+
createOutboxAttachmentUploadUrl,
|
|
508
722
|
storeDeduplicated,
|
|
509
723
|
storeParsedBody,
|
|
510
724
|
retrieveParsedBody,
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type OutboxSweepResult,
|
|
3
|
+
sweepAbandonedOutboxAttachments,
|
|
4
|
+
} from "./outbox-sweep.js";
|
|
5
|
+
export {
|
|
6
|
+
type AuthorizeUploadInput,
|
|
7
|
+
type AuthorizeUploadResult,
|
|
8
|
+
authorizeUploadRequest,
|
|
9
|
+
UPLOAD_ROUTE_PREFIX,
|
|
10
|
+
UPLOAD_URL_SIGNING_LABEL,
|
|
11
|
+
UPLOAD_URL_TTL_SECONDS,
|
|
12
|
+
} from "./outbox-upload-url.js";
|
|
13
|
+
export {
|
|
14
|
+
type SignedPathFailure,
|
|
15
|
+
type SignedPathResult,
|
|
16
|
+
signStoragePath,
|
|
17
|
+
verifyStoragePath,
|
|
18
|
+
} from "./signed-path.js";
|
|
1
19
|
export {
|
|
2
20
|
buildBodyPartKey,
|
|
3
21
|
buildDeduplicatedKey,
|
|
@@ -6,17 +24,24 @@ export {
|
|
|
6
24
|
buildExtractedSkippedKey,
|
|
7
25
|
buildExtractedTextKey,
|
|
8
26
|
buildMessageBodyKey,
|
|
27
|
+
buildOutboxAttachmentKey,
|
|
28
|
+
buildOutboxAttachmentPrefix,
|
|
9
29
|
buildParsedBodyKey,
|
|
10
30
|
type ContentEncodingValue,
|
|
31
|
+
type CreateOutboxAttachmentUploadUrlParams,
|
|
11
32
|
computeChecksum,
|
|
12
33
|
createMockStorageService,
|
|
13
34
|
type ExtractedTextListItem,
|
|
14
35
|
type ExtractedTextMarker,
|
|
15
36
|
isStorageNotFoundError,
|
|
37
|
+
type OutboxAttachmentListItem,
|
|
38
|
+
type OutboxAttachmentUploadTarget,
|
|
16
39
|
type ParsedAttachmentMeta,
|
|
17
40
|
type ParsedBody,
|
|
18
41
|
type ParsedContentStorageKey,
|
|
42
|
+
type ParsedOutboxAttachmentKey,
|
|
19
43
|
parseContentStorageKey,
|
|
44
|
+
parseOutboxAttachmentKey,
|
|
20
45
|
type StorageReference,
|
|
21
46
|
type StorageService,
|
|
22
47
|
type StorageTypeValue,
|
|
@@ -25,6 +50,7 @@ export {
|
|
|
25
50
|
type StoreExtractedSkippedParams,
|
|
26
51
|
type StoreExtractedTextParams,
|
|
27
52
|
type StoreMessageBodyParams,
|
|
53
|
+
type StoreOutboxAttachmentParams,
|
|
28
54
|
type StoreParsedBodyParams,
|
|
29
55
|
} from "./storage.js";
|
|
30
56
|
export {
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import { sweepAbandonedOutboxAttachments } from "./outbox-sweep.js";
|
|
4
|
+
import { createMockStorageService, type StorageService } from "./storage.js";
|
|
5
|
+
|
|
6
|
+
const CFG = "cfg1";
|
|
7
|
+
const ACC = "acc1";
|
|
8
|
+
|
|
9
|
+
const withObjects = async (
|
|
10
|
+
drafts: Record<string, string[]>,
|
|
11
|
+
): Promise<StorageService> => {
|
|
12
|
+
const storage = createMockStorageService();
|
|
13
|
+
for (const [outboxMessageId, ids] of Object.entries(drafts)) {
|
|
14
|
+
for (const outboxAttachmentId of ids) {
|
|
15
|
+
await storage.storeOutboxAttachment({
|
|
16
|
+
accountConfigId: CFG,
|
|
17
|
+
accountId: ACC,
|
|
18
|
+
outboxMessageId,
|
|
19
|
+
outboxAttachmentId,
|
|
20
|
+
content: Buffer.alloc(4),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return storage;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const remaining = (storage: StorageService, draft: string) =>
|
|
28
|
+
storage
|
|
29
|
+
.listOutboxAttachments(CFG, ACC, draft)
|
|
30
|
+
.then((items) => items.map((item) => item.outboxAttachmentId).sort());
|
|
31
|
+
|
|
32
|
+
describe("sweepAbandonedOutboxAttachments", () => {
|
|
33
|
+
test("deletes objects the database does not know about", async () => {
|
|
34
|
+
const storage = await withObjects({ draft1: ["known", "orphan"] });
|
|
35
|
+
|
|
36
|
+
const result = await sweepAbandonedOutboxAttachments(
|
|
37
|
+
{
|
|
38
|
+
storage,
|
|
39
|
+
listKnownAttachmentIds: async () => ["known"],
|
|
40
|
+
onSkipped: () => assert.fail("nothing should be skipped"),
|
|
41
|
+
},
|
|
42
|
+
CFG,
|
|
43
|
+
ACC,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
assert.deepStrictEqual(await remaining(storage, "draft1"), ["known"]);
|
|
47
|
+
assert.strictEqual(result.deleted, 1);
|
|
48
|
+
assert.strictEqual(result.skipped, 0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("empties the prefix of a draft the database has forgotten", async () => {
|
|
52
|
+
// A discarded draft has no rows at all, and a presigned PUT can still have
|
|
53
|
+
// landed under it. This is the case the sweep exists for.
|
|
54
|
+
const storage = await withObjects({ discarded: ["a", "b"] });
|
|
55
|
+
|
|
56
|
+
const result = await sweepAbandonedOutboxAttachments(
|
|
57
|
+
{
|
|
58
|
+
storage,
|
|
59
|
+
listKnownAttachmentIds: async () => [],
|
|
60
|
+
onSkipped: () => assert.fail("nothing should be skipped"),
|
|
61
|
+
},
|
|
62
|
+
CFG,
|
|
63
|
+
ACC,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
assert.deepStrictEqual(await remaining(storage, "discarded"), []);
|
|
67
|
+
assert.strictEqual(result.deleted, 2);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("leaves a draft alone when its rows cannot be read", async () => {
|
|
71
|
+
// "I could not find out what is live" must never be acted on as "nothing
|
|
72
|
+
// is live" — that would delete a whole draft's files on a transient error.
|
|
73
|
+
const storage = await withObjects({ draft1: ["a", "b"] });
|
|
74
|
+
const skips: string[] = [];
|
|
75
|
+
|
|
76
|
+
const result = await sweepAbandonedOutboxAttachments(
|
|
77
|
+
{
|
|
78
|
+
storage,
|
|
79
|
+
listKnownAttachmentIds: async () => {
|
|
80
|
+
throw new Error("database unavailable");
|
|
81
|
+
},
|
|
82
|
+
onSkipped: (outboxMessageId) => skips.push(outboxMessageId),
|
|
83
|
+
},
|
|
84
|
+
CFG,
|
|
85
|
+
ACC,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
assert.deepStrictEqual(await remaining(storage, "draft1"), ["a", "b"]);
|
|
89
|
+
assert.strictEqual(result.deleted, 0);
|
|
90
|
+
assert.strictEqual(result.skipped, 1);
|
|
91
|
+
assert.deepStrictEqual(skips, ["draft1"]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("one unreadable draft does not stop the others being collected", async () => {
|
|
95
|
+
const storage = await withObjects({ bad: ["x"], good: ["orphan"] });
|
|
96
|
+
|
|
97
|
+
const result = await sweepAbandonedOutboxAttachments(
|
|
98
|
+
{
|
|
99
|
+
storage,
|
|
100
|
+
listKnownAttachmentIds: async (_cfg, outboxMessageId) => {
|
|
101
|
+
if (outboxMessageId === "bad") throw new Error("unavailable");
|
|
102
|
+
return [];
|
|
103
|
+
},
|
|
104
|
+
onSkipped: () => {},
|
|
105
|
+
},
|
|
106
|
+
CFG,
|
|
107
|
+
ACC,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
assert.deepStrictEqual(await remaining(storage, "bad"), ["x"]);
|
|
111
|
+
assert.deepStrictEqual(await remaining(storage, "good"), []);
|
|
112
|
+
assert.strictEqual(result.skipped, 1);
|
|
113
|
+
assert.strictEqual(result.deleted, 1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("an attachment that lands between the two reads is kept", async () => {
|
|
117
|
+
// Objects are listed before the rows are read. A mint that lands in that
|
|
118
|
+
// window is seen as a row and not as an object, so it survives. Read the
|
|
119
|
+
// rows first and the same mint is an object with no row behind it — this
|
|
120
|
+
// fails if those two lines ever swap.
|
|
121
|
+
const storage = await withObjects({ draft1: ["already-there"] });
|
|
122
|
+
let listedObjects = false;
|
|
123
|
+
|
|
124
|
+
const inner = storage.listOutboxAttachments.bind(storage);
|
|
125
|
+
const observing = {
|
|
126
|
+
...storage,
|
|
127
|
+
listOutboxAttachments: async (
|
|
128
|
+
accountConfigId: string,
|
|
129
|
+
accountId: string,
|
|
130
|
+
outboxMessageId: string,
|
|
131
|
+
) => {
|
|
132
|
+
listedObjects = true;
|
|
133
|
+
return inner(accountConfigId, accountId, outboxMessageId);
|
|
134
|
+
},
|
|
135
|
+
} as StorageService;
|
|
136
|
+
|
|
137
|
+
await sweepAbandonedOutboxAttachments(
|
|
138
|
+
{
|
|
139
|
+
storage: observing,
|
|
140
|
+
listKnownAttachmentIds: async () => {
|
|
141
|
+
assert.strictEqual(
|
|
142
|
+
listedObjects,
|
|
143
|
+
true,
|
|
144
|
+
"rows must be read after the objects were listed, never before",
|
|
145
|
+
);
|
|
146
|
+
// The mint that landed in the window.
|
|
147
|
+
await storage.storeOutboxAttachment({
|
|
148
|
+
accountConfigId: CFG,
|
|
149
|
+
accountId: ACC,
|
|
150
|
+
outboxMessageId: "draft1",
|
|
151
|
+
outboxAttachmentId: "just-reserved",
|
|
152
|
+
content: Buffer.alloc(4),
|
|
153
|
+
});
|
|
154
|
+
return ["already-there", "just-reserved"];
|
|
155
|
+
},
|
|
156
|
+
onSkipped: () => {},
|
|
157
|
+
},
|
|
158
|
+
CFG,
|
|
159
|
+
ACC,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
assert.deepStrictEqual(await remaining(storage, "draft1"), [
|
|
163
|
+
"already-there",
|
|
164
|
+
"just-reserved",
|
|
165
|
+
]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("never reaches another tenant's objects", async () => {
|
|
169
|
+
const storage = createMockStorageService();
|
|
170
|
+
await storage.storeOutboxAttachment({
|
|
171
|
+
accountConfigId: "cfg-other",
|
|
172
|
+
accountId: ACC,
|
|
173
|
+
outboxMessageId: "draft1",
|
|
174
|
+
outboxAttachmentId: "theirs",
|
|
175
|
+
content: Buffer.alloc(4),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const result = await sweepAbandonedOutboxAttachments(
|
|
179
|
+
{
|
|
180
|
+
storage,
|
|
181
|
+
listKnownAttachmentIds: async () => [],
|
|
182
|
+
onSkipped: () => {},
|
|
183
|
+
},
|
|
184
|
+
CFG,
|
|
185
|
+
ACC,
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
assert.strictEqual(result.deleted, 0);
|
|
189
|
+
assert.deepStrictEqual(
|
|
190
|
+
(await storage.listOutboxAttachments("cfg-other", ACC, "draft1")).map(
|
|
191
|
+
(item) => item.outboxAttachmentId,
|
|
192
|
+
),
|
|
193
|
+
["theirs"],
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { StorageService } from "./storage.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Collect attachment bytes the database does not know about.
|
|
5
|
+
*
|
|
6
|
+
* A presigned upload URL stays valid for its whole window, including after the
|
|
7
|
+
* draft it was minted for is discarded, and on a deployment where the browser
|
|
8
|
+
* PUTs straight to block storage there is nothing in that path to consult. So
|
|
9
|
+
* an object can exist that no row names. That is the only case this exists for,
|
|
10
|
+
* and the rule is the whole of it: an object survives if the database still has
|
|
11
|
+
* an attachment row for it.
|
|
12
|
+
*
|
|
13
|
+
* Two orderings matter and both are deliberate. Objects are listed *before* the
|
|
14
|
+
* rows are read, so an attachment reserved between the two reads is seen as a
|
|
15
|
+
* row and not as an object, and is kept. And a draft whose row lookup fails is
|
|
16
|
+
* skipped entirely — "I could not find out what is live" must never be acted on
|
|
17
|
+
* as "nothing is live".
|
|
18
|
+
*/
|
|
19
|
+
export interface OutboxSweepResult {
|
|
20
|
+
inspected: number;
|
|
21
|
+
deleted: number;
|
|
22
|
+
/** Drafts skipped because their attachment rows could not be read. */
|
|
23
|
+
skipped: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface OutboxSweepDeps {
|
|
27
|
+
storage: StorageService;
|
|
28
|
+
/**
|
|
29
|
+
* The attachment ids the database holds for a draft. Throwing is the right
|
|
30
|
+
* way to say "unknown" — the sweep then leaves that draft alone.
|
|
31
|
+
*/
|
|
32
|
+
listKnownAttachmentIds: (
|
|
33
|
+
accountConfigId: string,
|
|
34
|
+
outboxMessageId: string,
|
|
35
|
+
) => Promise<string[]>;
|
|
36
|
+
onSkipped: (outboxMessageId: string, error: unknown) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const sweepAbandonedOutboxAttachments = async (
|
|
40
|
+
deps: OutboxSweepDeps,
|
|
41
|
+
accountConfigId: string,
|
|
42
|
+
accountId: string,
|
|
43
|
+
): Promise<OutboxSweepResult> => {
|
|
44
|
+
let inspected = 0;
|
|
45
|
+
let deleted = 0;
|
|
46
|
+
let skipped = 0;
|
|
47
|
+
|
|
48
|
+
const outboxMessageIds = await deps.storage.listOutboxDraftsWithAttachments(
|
|
49
|
+
accountConfigId,
|
|
50
|
+
accountId,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
for (const outboxMessageId of outboxMessageIds) {
|
|
54
|
+
const objects = await deps.storage.listOutboxAttachments(
|
|
55
|
+
accountConfigId,
|
|
56
|
+
accountId,
|
|
57
|
+
outboxMessageId,
|
|
58
|
+
);
|
|
59
|
+
if (objects.length === 0) continue;
|
|
60
|
+
|
|
61
|
+
const known = await deps
|
|
62
|
+
.listKnownAttachmentIds(accountConfigId, outboxMessageId)
|
|
63
|
+
.then((ids) => new Set(ids))
|
|
64
|
+
.catch((error: unknown) => {
|
|
65
|
+
deps.onSkipped(outboxMessageId, error);
|
|
66
|
+
return null;
|
|
67
|
+
});
|
|
68
|
+
if (known === null) {
|
|
69
|
+
skipped += 1;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
inspected += objects.length;
|
|
74
|
+
const abandoned = objects.filter(
|
|
75
|
+
(object) => !known.has(object.outboxAttachmentId),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// A draft whose every object is abandoned is the common case — it was
|
|
79
|
+
// discarded — and taking the prefix in one call beats one round trip per
|
|
80
|
+
// object.
|
|
81
|
+
if (abandoned.length === objects.length) {
|
|
82
|
+
await deps.storage.deleteOutboxAttachments(
|
|
83
|
+
accountConfigId,
|
|
84
|
+
accountId,
|
|
85
|
+
outboxMessageId,
|
|
86
|
+
);
|
|
87
|
+
deleted += abandoned.length;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const object of abandoned) {
|
|
92
|
+
await deps.storage.deleteOutboxAttachment(
|
|
93
|
+
accountConfigId,
|
|
94
|
+
accountId,
|
|
95
|
+
outboxMessageId,
|
|
96
|
+
object.outboxAttachmentId,
|
|
97
|
+
);
|
|
98
|
+
deleted += 1;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { inspected, deleted, skipped };
|
|
103
|
+
};
|