@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/src/storage.ts CHANGED
@@ -50,6 +50,52 @@ export interface StoreBodyPartParams {
50
50
  contentType?: string;
51
51
  }
52
52
 
53
+ /**
54
+ * Parameters for storing a file attached to a draft outbox message.
55
+ *
56
+ * Deliberately not the deduplicated path: a content-addressed object belongs to
57
+ * no single draft, so nothing may delete it when the draft goes. A per-draft key
58
+ * dies with the draft.
59
+ */
60
+ export interface StoreOutboxAttachmentParams {
61
+ accountConfigId: string;
62
+ accountId: string;
63
+ outboxMessageId: string;
64
+ outboxAttachmentId: string;
65
+ content: Buffer;
66
+ }
67
+
68
+ /**
69
+ * One uploaded object under a draft's attachment prefix. Reservations are not
70
+ * here — they live in the ledger, which is the only thing that can say whether
71
+ * an object present in storage is an attachment or garbage.
72
+ */
73
+ export interface OutboxAttachmentListItem {
74
+ outboxAttachmentId: string;
75
+ key: string;
76
+ sizeBytes: number;
77
+ }
78
+
79
+ /** Somewhere for a client to PUT one file, for a while */
80
+ export interface OutboxAttachmentUploadTarget {
81
+ /**
82
+ * Absolute URL the client PUTs raw bytes to. Opaque by design: block storage
83
+ * on one deployment, this deployment's own upload route on another, and a
84
+ * client cannot tell which it was handed.
85
+ */
86
+ uploadUrl: string;
87
+ }
88
+
89
+ export interface CreateOutboxAttachmentUploadUrlParams {
90
+ accountConfigId: string;
91
+ accountId: string;
92
+ outboxMessageId: string;
93
+ outboxAttachmentId: string;
94
+ /** Exact byte count the upload may carry. */
95
+ sizeBytes: number;
96
+ expiresAt: number;
97
+ }
98
+
53
99
  /** Parameters for storing deduplicated content (attachments shared across messages) */
54
100
  export interface StoreDeduplicatedParams {
55
101
  accountConfigId: string;
@@ -157,6 +203,80 @@ export interface StorageService {
157
203
  partPath: string,
158
204
  ): Promise<Buffer | null>;
159
205
 
206
+ /**
207
+ * Store a file attached to a draft outbox message, uncompressed: the size on
208
+ * the backend is then the decoded size the per-message cap is expressed in,
209
+ * and `listOutboxAttachments` can total a draft without opening anything.
210
+ */
211
+ storeOutboxAttachment(
212
+ params: StoreOutboxAttachmentParams,
213
+ ): Promise<StorageReference>;
214
+
215
+ /**
216
+ * Every object stored against a draft, paged to the end. Only the sweep reads
217
+ * this, and a sweep that stops early leaves exactly the orphan it was called
218
+ * to collect — so there is no limit to get wrong.
219
+ */
220
+ listOutboxAttachments(
221
+ accountConfigId: string,
222
+ accountId: string,
223
+ outboxMessageId: string,
224
+ ): Promise<OutboxAttachmentListItem[]>;
225
+
226
+ /**
227
+ * Remove every file and reservation stored against a draft. Unlike the read
228
+ * above this takes no limit and pages to the end: a sweep that stops early
229
+ * leaves exactly the orphan it was called to prevent.
230
+ */
231
+ deleteOutboxAttachments(
232
+ accountConfigId: string,
233
+ accountId: string,
234
+ outboxMessageId: string,
235
+ ): Promise<void>;
236
+
237
+ /**
238
+ * Every draft under an account that has attachment objects in storage,
239
+ * including drafts whose row is long gone. The sweep needs the prefixes that
240
+ * exist, not the rows that exist — a discarded draft is invisible to the
241
+ * database and is exactly the case worth collecting.
242
+ */
243
+ listOutboxDraftsWithAttachments(
244
+ accountConfigId: string,
245
+ accountId: string,
246
+ ): Promise<string[]>;
247
+
248
+ /** Remove one attachment object. */
249
+ deleteOutboxAttachment(
250
+ accountConfigId: string,
251
+ accountId: string,
252
+ outboxMessageId: string,
253
+ outboxAttachmentId: string,
254
+ ): Promise<void>;
255
+
256
+ /**
257
+ * The size storage actually holds for an attachment, or null when nothing
258
+ * was uploaded. This is the only number a completion may believe — the
259
+ * client's word for it is not evidence, and on a deployment where the bytes
260
+ * went straight to block storage it is the only reading available.
261
+ */
262
+ statOutboxAttachment(
263
+ accountConfigId: string,
264
+ accountId: string,
265
+ outboxMessageId: string,
266
+ outboxAttachmentId: string,
267
+ ): Promise<{ sizeBytes: number } | null>;
268
+
269
+ /**
270
+ * Mint somewhere to PUT one file. The backend decides what that is — block
271
+ * storage direct, or this deployment's upload route — and the caller never
272
+ * branches on which it got. Whatever it returns must bind the size: an
273
+ * upload that carries more than was reserved has to fail at the thing
274
+ * receiving it, not afterwards.
275
+ */
276
+ createOutboxAttachmentUploadUrl(
277
+ params: CreateOutboxAttachmentUploadUrlParams,
278
+ ): Promise<OutboxAttachmentUploadTarget>;
279
+
160
280
  /** Store deduplicated content (content-addressable, for attachments) */
161
281
  storeDeduplicated(params: StoreDeduplicatedParams): Promise<StorageReference>;
162
282
 
@@ -312,6 +432,54 @@ export const buildExtractedPrefix = (
312
432
  ): string =>
313
433
  `accounts/${accountConfigId}/${accountId}/messages/${messageId}/extracted/`;
314
434
 
435
+ export const buildOutboxAttachmentPrefix = (
436
+ accountConfigId: string,
437
+ accountId: string,
438
+ outboxMessageId: string,
439
+ ): string =>
440
+ `accounts/${accountConfigId}/${accountId}/outbox/${outboxMessageId}/attachments/`;
441
+
442
+ export const buildOutboxAttachmentKey = (
443
+ accountConfigId: string,
444
+ accountId: string,
445
+ outboxMessageId: string,
446
+ outboxAttachmentId: string,
447
+ ): string =>
448
+ `${buildOutboxAttachmentPrefix(accountConfigId, accountId, outboxMessageId)}${outboxAttachmentId}`;
449
+
450
+ // Every segment is a generated id, so the shape is closed rather than
451
+ // "anything without a slash": `..` matches `[^/]+` and the write side joins
452
+ // this key onto a filesystem root. The read side has `resolveContentPath` for
453
+ // the same reason (#310 review P1) — a signature should not be the only thing
454
+ // standing between a URL and the storage root.
455
+ const OUTBOX_ATTACHMENT_KEY =
456
+ /^accounts\/([A-Za-z0-9_-]+)\/([A-Za-z0-9_-]+)\/outbox\/([A-Za-z0-9_-]+)\/attachments\/([A-Za-z0-9_-]+)$/;
457
+ export interface ParsedOutboxAttachmentKey {
458
+ accountConfigId: string;
459
+ accountId: string;
460
+ outboxMessageId: string;
461
+ outboxAttachmentId: string;
462
+ }
463
+
464
+ /**
465
+ * Read the ids back out of an attachment key — the inverse of
466
+ * `buildOutboxAttachmentKey`, for the upload route, which is handed a path and
467
+ * has to work out what it addresses. Returns null on anything else, including a
468
+ * reservation key: bytes are never written to one.
469
+ */
470
+ export const parseOutboxAttachmentKey = (
471
+ storageKey: string,
472
+ ): ParsedOutboxAttachmentKey | null => {
473
+ const match = storageKey.match(OUTBOX_ATTACHMENT_KEY);
474
+ if (!match) return null;
475
+ return {
476
+ accountConfigId: match[1],
477
+ accountId: match[2],
478
+ outboxMessageId: match[3],
479
+ outboxAttachmentId: match[4],
480
+ };
481
+ };
482
+
315
483
  export const buildDeduplicatedKey = (
316
484
  accountConfigId: string,
317
485
  accountId: string,
@@ -411,6 +579,90 @@ export const createMockStorageService = (): StorageService => {
411
579
  );
412
580
  };
413
581
 
582
+ const storeOutboxAttachment: StorageService["storeOutboxAttachment"] = async (
583
+ params,
584
+ ) => {
585
+ const {
586
+ accountConfigId,
587
+ accountId,
588
+ outboxMessageId,
589
+ outboxAttachmentId,
590
+ content,
591
+ } = params;
592
+ return storeInternal(
593
+ buildOutboxAttachmentKey(
594
+ accountConfigId,
595
+ accountId,
596
+ outboxMessageId,
597
+ outboxAttachmentId,
598
+ ),
599
+ content,
600
+ );
601
+ };
602
+
603
+ const listOutboxAttachments: StorageService["listOutboxAttachments"] = async (
604
+ accountConfigId,
605
+ accountId,
606
+ outboxMessageId,
607
+ ) => {
608
+ const prefix = `mock://${buildOutboxAttachmentPrefix(accountConfigId, accountId, outboxMessageId)}`;
609
+ const items: OutboxAttachmentListItem[] = [];
610
+ for (const [uri, content] of storage.entries()) {
611
+ if (!uri.startsWith(prefix)) continue;
612
+ items.push({
613
+ outboxAttachmentId: uri.slice(prefix.length),
614
+ key: uri.slice("mock://".length),
615
+ sizeBytes: content.length,
616
+ });
617
+ }
618
+ return items;
619
+ };
620
+
621
+ const listOutboxDraftsWithAttachments: StorageService["listOutboxDraftsWithAttachments"] =
622
+ async (accountConfigId, accountId) => {
623
+ const root = `mock://accounts/${accountConfigId}/${accountId}/outbox/`;
624
+ const drafts = new Set<string>();
625
+ for (const uri of storage.keys()) {
626
+ if (!uri.startsWith(root)) continue;
627
+ const rest = uri.slice(root.length);
628
+ const slash = rest.indexOf("/");
629
+ if (slash > 0) drafts.add(rest.slice(0, slash));
630
+ }
631
+ return [...drafts];
632
+ };
633
+
634
+ const deleteOutboxAttachment: StorageService["deleteOutboxAttachment"] =
635
+ async (accountConfigId, accountId, outboxMessageId, outboxAttachmentId) => {
636
+ storage.delete(
637
+ `mock://${buildOutboxAttachmentKey(accountConfigId, accountId, outboxMessageId, outboxAttachmentId)}`,
638
+ );
639
+ };
640
+
641
+ const statOutboxAttachment: StorageService["statOutboxAttachment"] = async (
642
+ accountConfigId,
643
+ accountId,
644
+ outboxMessageId,
645
+ outboxAttachmentId,
646
+ ) => {
647
+ const content = storage.get(
648
+ `mock://${buildOutboxAttachmentKey(accountConfigId, accountId, outboxMessageId, outboxAttachmentId)}`,
649
+ );
650
+ return content ? { sizeBytes: content.length } : null;
651
+ };
652
+
653
+ const createOutboxAttachmentUploadUrl: StorageService["createOutboxAttachmentUploadUrl"] =
654
+ async (params) => ({
655
+ uploadUrl: `mock://upload/${buildOutboxAttachmentKey(params.accountConfigId, params.accountId, params.outboxMessageId, params.outboxAttachmentId)}?max=${params.sizeBytes}&exp=${params.expiresAt}`,
656
+ });
657
+
658
+ const deleteOutboxAttachments: StorageService["deleteOutboxAttachments"] =
659
+ async (accountConfigId, accountId, outboxMessageId) => {
660
+ const prefix = `mock://${buildOutboxAttachmentPrefix(accountConfigId, accountId, outboxMessageId)}`;
661
+ for (const uri of [...storage.keys()]) {
662
+ if (uri.startsWith(prefix)) storage.delete(uri);
663
+ }
664
+ };
665
+
414
666
  const storeDeduplicated: StorageService["storeDeduplicated"] = async (
415
667
  params,
416
668
  ) => {
@@ -547,6 +799,13 @@ export const createMockStorageService = (): StorageService => {
547
799
  storeBodyPart,
548
800
  bodyPartExists,
549
801
  retrieveBodyPart,
802
+ storeOutboxAttachment,
803
+ listOutboxAttachments,
804
+ listOutboxDraftsWithAttachments,
805
+ deleteOutboxAttachments,
806
+ deleteOutboxAttachment,
807
+ statOutboxAttachment,
808
+ createOutboxAttachmentUploadUrl,
550
809
  storeDeduplicated,
551
810
  storeParsedBody,
552
811
  retrieveParsedBody,