@remit/storage-service 0.0.7 → 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.
@@ -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
+ };