@remit/storage-service 0.0.16 → 0.0.17

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.
@@ -1,5 +1,5 @@
1
1
  import { createWriteStream } from "node:fs";
2
- import { mkdir, readdir, readFile, rm, stat, unlink, writeFile, } from "node:fs/promises";
2
+ import { mkdir, readdir, readFile, rm, rmdir, stat, unlink, writeFile, } from "node:fs/promises";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { Readable } from "node:stream";
5
5
  import { pipeline } from "node:stream/promises";
@@ -10,6 +10,17 @@ import { UPLOAD_ROUTE_PREFIX, UPLOAD_URL_SIGNING_LABEL, } from "../outbox-upload
10
10
  import { signStoragePath } from "../signed-path.js";
11
11
  import { buildBodyPartKey, buildDeduplicatedKey, buildExportArchiveKey, buildMessageBodyKey, buildOutboxAttachmentKey, buildOutboxAttachmentPrefix, buildParsedBodyKey, computeChecksum, isStorageNotFoundError, } from "../storage.js";
12
12
  const gzipAsync = promisify(gzip);
13
+ const DIRECTORY_IN_USE = new Set(["ENOENT", "ENOTEMPTY", "EEXIST"]);
14
+ const keepDirectoryInUse = (error) => {
15
+ if (typeof error === "object" &&
16
+ error !== null &&
17
+ "code" in error &&
18
+ typeof error.code === "string" &&
19
+ DIRECTORY_IN_USE.has(error.code)) {
20
+ return;
21
+ }
22
+ throw error;
23
+ };
13
24
  export const createFilesystemStorageService = (basePath, uploadUrls) => {
14
25
  const storeInternal = async (params) => {
15
26
  const { key, content, compress = true } = params;
@@ -173,6 +184,14 @@ export const createFilesystemStorageService = (basePath, uploadUrls) => {
173
184
  const deletePrefix = async (prefix) => {
174
185
  await rm(join(basePath, prefix), { recursive: true, force: true });
175
186
  };
187
+ const deleteKeys = async (keys) => {
188
+ const paths = keys.map((key) => join(basePath, key));
189
+ await Promise.all(paths.map((path) => rm(path, { force: true })));
190
+ const directories = [...new Set(paths.map((path) => dirname(path)))].sort((a, b) => b.length - a.length);
191
+ for (const directory of directories) {
192
+ await rmdir(directory).catch(keepDirectoryInUse);
193
+ }
194
+ };
176
195
  const storeDeduplicated = (params) => {
177
196
  const { accountConfigId, accountId, content } = params;
178
197
  const checksumSha256 = computeChecksum(content);
@@ -245,6 +264,7 @@ export const createFilesystemStorageService = (basePath, uploadUrls) => {
245
264
  listOutboxDraftsWithAttachments,
246
265
  deleteOutboxAttachments,
247
266
  deletePrefix,
267
+ deleteKeys,
248
268
  deleteOutboxAttachment,
249
269
  statOutboxAttachment,
250
270
  retrieveOutboxAttachment,
@@ -7,6 +7,7 @@ import { ContentEncoding, StorageType } from "@remit/domain-enums";
7
7
  import { buildBodyPartKey, buildDeduplicatedKey, buildExportArchiveKey, buildMessageBodyKey, buildOutboxAttachmentKey, buildOutboxAttachmentPrefix, buildParsedBodyKey, computeChecksum, isStorageNotFoundError, } from "../storage.js";
8
8
  import { parseStorageUri } from "../uri.js";
9
9
  const gzipAsync = promisify(gzip);
10
+ const STORAGE_DELETE_BATCH_SIZE = 1000;
10
11
  export const createS3StorageService = (client, bucketName) => {
11
12
  const storeInternal = async (params) => {
12
13
  const { key, content, contentType, compress = true } = params;
@@ -216,6 +217,19 @@ export const createS3StorageService = (client, bucketName) => {
216
217
  : undefined;
217
218
  } while (continuationToken);
218
219
  };
220
+ const deleteKeys = async (keys) => {
221
+ for (let start = 0; start < keys.length; start += STORAGE_DELETE_BATCH_SIZE) {
222
+ const batch = keys.slice(start, start + STORAGE_DELETE_BATCH_SIZE);
223
+ const response = await client.send(new DeleteObjectsCommand({
224
+ Bucket: bucketName,
225
+ Delete: { Objects: batch.map((Key) => ({ Key })), Quiet: true },
226
+ }));
227
+ const failed = response.Errors ?? [];
228
+ if (failed.length > 0) {
229
+ throw new Error(`S3 refused to delete ${failed.length} of ${batch.length} objects, first ${failed[0].Key}: ${failed[0].Code} ${failed[0].Message}`);
230
+ }
231
+ }
232
+ };
219
233
  const deleteOutboxAttachments = (accountConfigId, accountId, outboxMessageId) => deletePrefix(buildOutboxAttachmentPrefix(accountConfigId, accountId, outboxMessageId));
220
234
  const storeDeduplicated = (params) => {
221
235
  const { accountConfigId, accountId, content, contentType } = params;
@@ -359,6 +373,7 @@ export const createS3StorageService = (client, bucketName) => {
359
373
  listOutboxDraftsWithAttachments,
360
374
  deleteOutboxAttachments,
361
375
  deletePrefix,
376
+ deleteKeys,
362
377
  deleteOutboxAttachment,
363
378
  statOutboxAttachment,
364
379
  retrieveOutboxAttachment,
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { type OutboxSweepResult, sweepAbandonedOutboxAttachments, } from "./outbox-sweep.js";
2
2
  export { type AuthorizeUploadInput, type AuthorizeUploadResult, authorizeUploadRequest, UPLOAD_ROUTE_PREFIX, UPLOAD_URL_SIGNING_LABEL, UPLOAD_URL_TTL_SECONDS, } from "./outbox-upload-url.js";
3
3
  export { type SignedPathFailure, type SignedPathResult, signStoragePath, verifyStoragePath, } from "./signed-path.js";
4
- export { buildBodyPartKey, buildDeduplicatedKey, buildExportArchiveKey, buildMessageBodyKey, buildOutboxAttachmentKey, buildOutboxAttachmentPrefix, buildParsedBodyKey, type ContentEncodingValue, type CreateOutboxAttachmentUploadUrlParams, computeChecksum, createMockStorageService, isStorageNotFoundError, messageObjectPrefixOf, type OutboxAttachmentListItem, type OutboxAttachmentUploadTarget, type ParsedAttachmentMeta, type ParsedBody, type ParsedContentStorageKey, type ParsedOutboxAttachmentKey, parseContentStorageKey, parseOutboxAttachmentKey, type StorageReference, type StorageService, type StorageTypeValue, type StoreBodyPartParams, type StoreDeduplicatedParams, type StoreMessageBodyParams, type StoreOutboxAttachmentParams, type StoreParsedBodyParams, } from "./storage.js";
4
+ export { buildBodyPartKey, buildDeduplicatedKey, buildExportArchiveKey, buildMessageBodyKey, buildOutboxAttachmentKey, buildOutboxAttachmentPrefix, buildParsedBodyKey, type ContentEncodingValue, type CreateOutboxAttachmentUploadUrlParams, computeChecksum, createMockStorageService, isStorageNotFoundError, MATERIALIZED_SENTINEL_PART_PATH, MATERIALIZED_SENTINEL_V2_PART_PATH, type MessageObjectKeys, messageObjectKeysOf, type OutboxAttachmentListItem, type OutboxAttachmentUploadTarget, type ParsedAttachmentMeta, type ParsedBody, type ParsedContentStorageKey, type ParsedOutboxAttachmentKey, parseContentStorageKey, parseOutboxAttachmentKey, type StorageReference, type StorageService, type StorageTypeValue, type StoreBodyPartParams, type StoreDeduplicatedParams, type StoreMessageBodyParams, type StoreOutboxAttachmentParams, type StoreParsedBodyParams, } from "./storage.js";
5
5
  export { buildStorageUri, type ParsedStorageUri, parseStorageUri, } from "./uri.js";
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { sweepAbandonedOutboxAttachments, } from "./outbox-sweep.js";
2
2
  export { authorizeUploadRequest, UPLOAD_ROUTE_PREFIX, UPLOAD_URL_SIGNING_LABEL, UPLOAD_URL_TTL_SECONDS, } from "./outbox-upload-url.js";
3
3
  export { signStoragePath, verifyStoragePath, } from "./signed-path.js";
4
- export { buildBodyPartKey, buildDeduplicatedKey, buildExportArchiveKey, buildMessageBodyKey, buildOutboxAttachmentKey, buildOutboxAttachmentPrefix, buildParsedBodyKey, computeChecksum, createMockStorageService, isStorageNotFoundError, messageObjectPrefixOf, parseContentStorageKey, parseOutboxAttachmentKey, } from "./storage.js";
4
+ export { buildBodyPartKey, buildDeduplicatedKey, buildExportArchiveKey, buildMessageBodyKey, buildOutboxAttachmentKey, buildOutboxAttachmentPrefix, buildParsedBodyKey, computeChecksum, createMockStorageService, isStorageNotFoundError, MATERIALIZED_SENTINEL_PART_PATH, MATERIALIZED_SENTINEL_V2_PART_PATH, messageObjectKeysOf, parseContentStorageKey, parseOutboxAttachmentKey, } from "./storage.js";
5
5
  export { buildStorageUri, parseStorageUri, } from "./uri.js";
package/dist/storage.d.ts CHANGED
@@ -195,6 +195,11 @@ export interface StorageService {
195
195
  * in `/`. Deleting a prefix that holds nothing is a no-op.
196
196
  */
197
197
  deletePrefix(keyPrefix: string): Promise<void>;
198
+ /**
199
+ * Delete the objects at `keys`, issuing one backend call per 1000 keys and
200
+ * no listing. A key that holds nothing is skipped.
201
+ */
202
+ deleteKeys(keys: readonly string[]): Promise<void>;
198
203
  /**
199
204
  * Stream a finished export archive (already-compressed ZIP) to the export
200
205
  * key for the given config + request, uncompressed at rest. The body is
@@ -245,7 +250,35 @@ export interface ParsedContentStorageKey {
245
250
  * above). Returns null when the shape doesn't match.
246
251
  */
247
252
  export declare const parseContentStorageKey: (storageKey: string) => ParsedContentStorageKey | null;
248
- export declare const messageObjectPrefixOf: (bodyStorageUri: string) => string;
253
+ /**
254
+ * Sentinel object written under a message's parts prefix once lazy
255
+ * materialization has stored EVERY leaf. The read path checks it first with a
256
+ * single HEAD and early-returns, skipping the body.eml GET, the MIME re-parse,
257
+ * and the per-leaf HEADs on every warm re-open.
258
+ *
259
+ * Deliberately an S3-only marker (no Message-row flag). A leading dot keeps it
260
+ * out of any real IMAP section path (those are dot-separated digits).
261
+ */
262
+ export declare const MATERIALIZED_SENTINEL_PART_PATH = ".materialized";
263
+ /**
264
+ * The sentinel for a message carrying a forwarded message. Those parts were
265
+ * once stored under a layout in which the forwarded message's root overwrote
266
+ * its own row, so a `.materialized` written then vouches for objects at the
267
+ * wrong paths. Its own name makes every such message materialize once more,
268
+ * overwriting what is there; no other message is touched.
269
+ */
270
+ export declare const MATERIALIZED_SENTINEL_V2_PART_PATH = ".materialized.2";
271
+ export interface MessageObjectKeys {
272
+ own: string[];
273
+ shared: string[];
274
+ }
275
+ /**
276
+ * The storage keys a message's objects live at. `own` sit under the message's
277
+ * own id and no other message reads them. `shared` is the raw body
278
+ * `bodyStorageKey` names, which a copy shares with its source and every other
279
+ * copy, so it goes only once no row names it.
280
+ */
281
+ export declare const messageObjectKeysOf: (bodyStorageKey: string, messageId: string, partPaths: Iterable<string>) => MessageObjectKeys;
249
282
  export declare const computeChecksum: (content: Buffer) => string;
250
283
  export declare const buildExportArchiveKey: (accountConfigId: string, exportRequestId: string) => string;
251
284
  export declare const isStorageNotFoundError: (error: unknown) => boolean;
package/dist/storage.js CHANGED
@@ -7,9 +7,12 @@ import { parseStorageUri } from "./uri.js";
7
7
  // the edge can verify cross-tenant isolation at the URL prefix without
8
8
  // loading any database record. The inner `accountId` distinguishes between
9
9
  // multiple IMAP-account records owned by the same tenant.
10
- export const buildMessageBodyKey = (accountConfigId, accountId, messageId) => `accounts/${accountConfigId}/${accountId}/messages/${messageId}/body.eml`;
11
- export const buildParsedBodyKey = (accountConfigId, accountId, messageId) => `accounts/${accountConfigId}/${accountId}/messages/${messageId}/parsed.json.gz`;
12
- export const buildBodyPartKey = (accountConfigId, accountId, messageId, partPath) => `accounts/${accountConfigId}/${accountId}/messages/${messageId}/parts/${partPath}`;
10
+ const MESSAGE_BODY_OBJECT = "body.eml";
11
+ const PARSED_BODY_OBJECT = "parsed.json.gz";
12
+ const BODY_PARTS_PREFIX = "parts/";
13
+ export const buildMessageBodyKey = (accountConfigId, accountId, messageId) => `accounts/${accountConfigId}/${accountId}/messages/${messageId}/${MESSAGE_BODY_OBJECT}`;
14
+ export const buildParsedBodyKey = (accountConfigId, accountId, messageId) => `accounts/${accountConfigId}/${accountId}/messages/${messageId}/${PARSED_BODY_OBJECT}`;
15
+ export const buildBodyPartKey = (accountConfigId, accountId, messageId, partPath) => `accounts/${accountConfigId}/${accountId}/messages/${messageId}/${BODY_PARTS_PREFIX}${partPath}`;
13
16
  export const buildOutboxAttachmentPrefix = (accountConfigId, accountId, outboxMessageId) => `accounts/${accountConfigId}/${accountId}/outbox/${outboxMessageId}/attachments/`;
14
17
  export const buildOutboxAttachmentKey = (accountConfigId, accountId, outboxMessageId, outboxAttachmentId) => `${buildOutboxAttachmentPrefix(accountConfigId, accountId, outboxMessageId)}${outboxAttachmentId}`;
15
18
  // Every segment is a generated id, so the shape is closed rather than
@@ -52,13 +55,49 @@ export const parseContentStorageKey = (storageKey) => {
52
55
  messageId: match[3],
53
56
  };
54
57
  };
55
- const MESSAGE_OBJECT_PREFIX = /(?:^|\/)(accounts\/[^/]+\/(?:[^/]+\/)?messages\/[^/]+\/)[^/]+$/;
56
- export const messageObjectPrefixOf = (bodyStorageUri) => {
57
- const match = parseStorageUri(bodyStorageUri).storageKey.match(MESSAGE_OBJECT_PREFIX);
58
+ /**
59
+ * Sentinel object written under a message's parts prefix once lazy
60
+ * materialization has stored EVERY leaf. The read path checks it first with a
61
+ * single HEAD and early-returns, skipping the body.eml GET, the MIME re-parse,
62
+ * and the per-leaf HEADs on every warm re-open.
63
+ *
64
+ * Deliberately an S3-only marker (no Message-row flag). A leading dot keeps it
65
+ * out of any real IMAP section path (those are dot-separated digits).
66
+ */
67
+ export const MATERIALIZED_SENTINEL_PART_PATH = ".materialized";
68
+ /**
69
+ * The sentinel for a message carrying a forwarded message. Those parts were
70
+ * once stored under a layout in which the forwarded message's root overwrote
71
+ * its own row, so a `.materialized` written then vouches for objects at the
72
+ * wrong paths. Its own name makes every such message materialize once more,
73
+ * overwriting what is there; no other message is touched.
74
+ */
75
+ export const MATERIALIZED_SENTINEL_V2_PART_PATH = ".materialized.2";
76
+ const MESSAGE_BODY_KEY = /(?:^|\/)(accounts\/[^/]+\/(?:[^/]+\/)?messages\/)([^/]+)\/([^/]+)$/;
77
+ /**
78
+ * The storage keys a message's objects live at. `own` sit under the message's
79
+ * own id and no other message reads them. `shared` is the raw body
80
+ * `bodyStorageKey` names, which a copy shares with its source and every other
81
+ * copy, so it goes only once no row names it.
82
+ */
83
+ export const messageObjectKeysOf = (bodyStorageKey, messageId, partPaths) => {
84
+ const match = parseStorageUri(bodyStorageKey).storageKey.match(MESSAGE_BODY_KEY);
58
85
  if (!match) {
59
- throw new Error(`${bodyStorageUri} is not a message body key; its objects cannot be found to delete`);
86
+ throw new Error(`${bodyStorageKey} is not a message body key; its objects cannot be found to delete`);
60
87
  }
61
- return match[1];
88
+ const [, messages, ownerId, body] = match;
89
+ const own = `${messages}${messageId}/`;
90
+ return {
91
+ own: [
92
+ `${own}${PARSED_BODY_OBJECT}`,
93
+ ...[
94
+ MATERIALIZED_SENTINEL_PART_PATH,
95
+ MATERIALIZED_SENTINEL_V2_PART_PATH,
96
+ ...partPaths,
97
+ ].map((partPath) => `${own}${BODY_PARTS_PREFIX}${partPath}`),
98
+ ],
99
+ shared: [`${messages}${ownerId}/${body}`],
100
+ };
62
101
  };
63
102
  export const computeChecksum = (content) => createHash("sha256").update(content).digest("hex");
64
103
  export const buildExportArchiveKey = (accountConfigId, exportRequestId) => `exports/${accountConfigId}/${exportRequestId}/export.zip`;
@@ -147,6 +186,10 @@ export const createMockStorageService = () => {
147
186
  storage.delete(uri);
148
187
  }
149
188
  };
189
+ const deleteKeys = async (keys) => {
190
+ for (const key of keys)
191
+ storage.delete(`${MOCK_ROOT}${key}`);
192
+ };
150
193
  const deleteOutboxAttachments = (accountConfigId, accountId, outboxMessageId) => deletePrefix(buildOutboxAttachmentPrefix(accountConfigId, accountId, outboxMessageId));
151
194
  const storeDeduplicated = async (params) => {
152
195
  const { accountConfigId, accountId, content } = params;
@@ -194,6 +237,7 @@ export const createMockStorageService = () => {
194
237
  listOutboxDraftsWithAttachments,
195
238
  deleteOutboxAttachments,
196
239
  deletePrefix,
240
+ deleteKeys,
197
241
  deleteOutboxAttachment,
198
242
  statOutboxAttachment,
199
243
  retrieveOutboxAttachment,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/storage-service",
3
- "version": "0.0.16",
3
+ "version": "0.0.17",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",