@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.
- package/dist/backends/filesystem.js +21 -1
- package/dist/backends/s3.js +15 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/storage.d.ts +34 -1
- package/dist/storage.js +52 -8
- package/package.json +1 -1
|
@@ -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,
|
package/dist/backends/s3.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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(`${
|
|
86
|
+
throw new Error(`${bodyStorageKey} is not a message body key; its objects cannot be found to delete`);
|
|
60
87
|
}
|
|
61
|
-
|
|
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,
|