@h-ai/storage 0.1.0-alpha9 → 1.0.0-beta.10
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/README.en.md +289 -0
- package/README.ja.md +289 -0
- package/README.md +237 -59
- package/dist/browser.d.ts +491 -102
- package/dist/browser.js +3 -2
- package/dist/{chunk-KHZOR6CR.js → chunk-ANAESRWS.js} +39 -12
- package/dist/chunk-ANAESRWS.js.map +1 -0
- package/dist/chunk-P65WYXJG.js +33 -0
- package/dist/chunk-P65WYXJG.js.map +1 -0
- package/dist/chunk-TLZY4IP6.js +360 -0
- package/dist/chunk-TLZY4IP6.js.map +1 -0
- package/dist/client/index.d.ts +57 -48
- package/dist/client/index.js +1 -1
- package/dist/node.d.ts +15 -11
- package/dist/node.js +636 -224
- package/dist/node.js.map +1 -1
- package/dist/schemas.d.ts +103 -0
- package/dist/schemas.js +3 -0
- package/dist/schemas.js.map +1 -0
- package/package.json +26 -17
- package/dist/api/index.d.ts +0 -167
- package/dist/api/index.js +0 -105
- package/dist/api/index.js.map +0 -1
- package/dist/chunk-KHZOR6CR.js.map +0 -1
- package/dist/chunk-U427BQIQ.js +0 -266
- package/dist/chunk-U427BQIQ.js.map +0 -1
package/dist/node.js
CHANGED
|
@@ -1,19 +1,123 @@
|
|
|
1
|
-
import { HaiStorageError, StorageConfigSchema } from './chunk-
|
|
2
|
-
export { HaiStorageError, LocalConfigSchema, PresignOptionsSchema, PresignUploadOptionsSchema, S3ConfigSchema, StorageConfigSchema, StorageTypeSchema } from './chunk-
|
|
3
|
-
import { storageM, MIME_TYPES, MIME_TYPE_DEFAULT } from './chunk-
|
|
4
|
-
export { downloadAndSave, downloadWithPresignedUrl, formatFileSize, getFileExtension, getMimeType, uploadWithPresignedUrl } from './chunk-
|
|
1
|
+
import { HaiStorageError, StorageConfigSchema, PresignUploadOptionsSchema, PresignOptionsSchema } from './chunk-ANAESRWS.js';
|
|
2
|
+
export { HaiStorageError, LocalConfigSchema, PresignOptionsSchema, PresignUploadOptionsSchema, S3ConfigSchema, StorageConfigSchema, StorageOperationLogConfigSchema, StorageTypeSchema } from './chunk-ANAESRWS.js';
|
|
3
|
+
import { storageM, MIME_TYPES, MIME_TYPE_DEFAULT } from './chunk-TLZY4IP6.js';
|
|
4
|
+
export { downloadAndSave, downloadWithPresignedUrl, formatFileSize, getFileExtension, getMimeType, uploadWithPresignedUrl } from './chunk-TLZY4IP6.js';
|
|
5
|
+
export { STORAGE_DELETE_MAX_KEYS, STORAGE_KEY_MAX_BYTES, StorageDeleteKeysSchema, StorageKeySchema, StoragePrefixSchema, StoragePresignExpiresInSchema, getStorageKeyIssue } from './chunk-P65WYXJG.js';
|
|
6
|
+
import process from 'node:process';
|
|
5
7
|
import { core, err, ok } from '@h-ai/core';
|
|
6
|
-
import { Buffer } from 'buffer';
|
|
7
|
-
import * as crypto from 'crypto';
|
|
8
|
-
import
|
|
9
|
-
import * as
|
|
10
|
-
import * as
|
|
11
|
-
import
|
|
8
|
+
import { Buffer } from 'node:buffer';
|
|
9
|
+
import * as crypto from 'node:crypto';
|
|
10
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import * as fsp from 'node:fs/promises';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
import { Readable } from 'node:stream';
|
|
15
|
+
import { pipeline } from 'node:stream/promises';
|
|
16
|
+
import { S3Client, HeadBucketCommand, CreateBucketCommand, ListObjectsV2Command, PutObjectCommand, GetObjectCommand, CopyObjectCommand, DeleteObjectsCommand, DeleteObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
|
|
12
17
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
13
18
|
|
|
19
|
+
function toStorageDeleteResult(summary) {
|
|
20
|
+
if (summary.failures.length === 0)
|
|
21
|
+
return ok(summary);
|
|
22
|
+
return {
|
|
23
|
+
success: false,
|
|
24
|
+
error: {
|
|
25
|
+
...HaiStorageError.OPERATION_FAILED,
|
|
26
|
+
message: storageM("storage_deleteManyFailed", { params: { count: summary.failures.length } }),
|
|
27
|
+
ext: { deleteMany: summary }
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
async function* toByteStream(data, signal) {
|
|
32
|
+
signal?.throwIfAborted();
|
|
33
|
+
if (typeof data === "string" || data instanceof Uint8Array) {
|
|
34
|
+
yield typeof data === "string" ? Buffer.from(data) : data;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const iterator = data[Symbol.asyncIterator]();
|
|
38
|
+
let completed = false;
|
|
39
|
+
try {
|
|
40
|
+
while (true) {
|
|
41
|
+
signal?.throwIfAborted();
|
|
42
|
+
let result;
|
|
43
|
+
let onAbort;
|
|
44
|
+
try {
|
|
45
|
+
const cancelled = signal ? new Promise((_, reject) => {
|
|
46
|
+
onAbort = () => reject(signal.reason);
|
|
47
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
48
|
+
}) : void 0;
|
|
49
|
+
const next = iterator.next();
|
|
50
|
+
result = await (cancelled ? Promise.race([next, cancelled]) : next);
|
|
51
|
+
} finally {
|
|
52
|
+
if (onAbort)
|
|
53
|
+
signal?.removeEventListener("abort", onAbort);
|
|
54
|
+
}
|
|
55
|
+
if (result.done) {
|
|
56
|
+
completed = true;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
if (!(result.value instanceof Uint8Array))
|
|
60
|
+
throw new TypeError(storageM("storage_streamInvalidChunk"));
|
|
61
|
+
yield result.value;
|
|
62
|
+
}
|
|
63
|
+
signal?.throwIfAborted();
|
|
64
|
+
} finally {
|
|
65
|
+
if (!completed && iterator.return) {
|
|
66
|
+
if (signal?.aborted)
|
|
67
|
+
void Promise.resolve(iterator.return()).catch(() => {
|
|
68
|
+
});
|
|
69
|
+
else
|
|
70
|
+
await iterator.return();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// src/providers/storage-operation-log.ts
|
|
76
|
+
function normalizePayloadValue(value) {
|
|
77
|
+
if (value instanceof Uint8Array) {
|
|
78
|
+
return { byteLength: value.byteLength };
|
|
79
|
+
}
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
function stringifyOperationPayload(payload, maxLength) {
|
|
83
|
+
if (maxLength <= 0) {
|
|
84
|
+
return "";
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const text = JSON.stringify(payload, (_key, value) => {
|
|
88
|
+
if (typeof value === "bigint") {
|
|
89
|
+
return value.toString();
|
|
90
|
+
}
|
|
91
|
+
if (typeof value === "function") {
|
|
92
|
+
return "[Function]";
|
|
93
|
+
}
|
|
94
|
+
return normalizePayloadValue(value);
|
|
95
|
+
});
|
|
96
|
+
return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;
|
|
97
|
+
} catch {
|
|
98
|
+
const text = String(payload);
|
|
99
|
+
return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function logStorageOperation(logger4, config, category, operation, payload) {
|
|
103
|
+
if (!config?.[category]) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
logger4[config.level]("Storage operation executed", {
|
|
107
|
+
category,
|
|
108
|
+
operation,
|
|
109
|
+
payload: stringifyOperationPayload(payload, config.maxLength)
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// src/providers/storage-provider-local.ts
|
|
14
114
|
var logger = core.logger.child({ module: "storage", scope: "provider-local" });
|
|
115
|
+
var PATH_SEGMENT_SPLIT_REGEX = /[\\/]+/;
|
|
15
116
|
function toStorageError(error, key) {
|
|
16
117
|
const e = error;
|
|
118
|
+
if (e.code === "EEXIST") {
|
|
119
|
+
return { ...HaiStorageError.ALREADY_EXISTS, message: storageM("storage_fileAlreadyExists", { params: { key: key ?? "" } }), cause: error };
|
|
120
|
+
}
|
|
17
121
|
if (e.code === "ENOENT") {
|
|
18
122
|
return {
|
|
19
123
|
...HaiStorageError.NOT_FOUND,
|
|
@@ -63,7 +167,8 @@ function calculateEtag(stat2) {
|
|
|
63
167
|
return `"${stat2.size.toString(16)}-${stat2.mtimeMs.toString(16)}"`;
|
|
64
168
|
}
|
|
65
169
|
function isMetaFile(key) {
|
|
66
|
-
|
|
170
|
+
const normalized = key.toLowerCase();
|
|
171
|
+
return normalized.endsWith(".meta.json") || normalized.endsWith(".upload.tmp");
|
|
67
172
|
}
|
|
68
173
|
function createInvalidPathError() {
|
|
69
174
|
const error = new Error(storageM("storage_pathTraversal"));
|
|
@@ -71,14 +176,14 @@ function createInvalidPathError() {
|
|
|
71
176
|
return error;
|
|
72
177
|
}
|
|
73
178
|
function hasPathTraversalSegment(key) {
|
|
74
|
-
const segments = key.split(
|
|
179
|
+
const segments = key.split(PATH_SEGMENT_SPLIT_REGEX).filter(Boolean);
|
|
75
180
|
return segments.includes("..");
|
|
76
181
|
}
|
|
77
182
|
function safePath(root, key) {
|
|
78
183
|
if (path.isAbsolute(key)) {
|
|
79
184
|
throw createInvalidPathError();
|
|
80
185
|
}
|
|
81
|
-
if (key.includes("\0") || hasPathTraversalSegment(key)) {
|
|
186
|
+
if (key.includes("\0") || hasPathTraversalSegment(key) || process.platform === "win32" && key.split(PATH_SEGMENT_SPLIT_REGEX).some((segment) => /[:. ]$/.test(segment) || segment.includes(":"))) {
|
|
82
187
|
throw createInvalidPathError();
|
|
83
188
|
}
|
|
84
189
|
const normalized = path.normalize(key);
|
|
@@ -99,17 +204,25 @@ function createLocalProvider() {
|
|
|
99
204
|
}
|
|
100
205
|
return config;
|
|
101
206
|
}
|
|
102
|
-
function fullPath(key) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
207
|
+
async function fullPath(key) {
|
|
208
|
+
const root = getConfig().root;
|
|
209
|
+
const target = safePath(root, key);
|
|
210
|
+
let current = root;
|
|
211
|
+
for (const segment of path.relative(root, target).split(path.sep).filter(Boolean)) {
|
|
212
|
+
current = path.join(current, segment);
|
|
213
|
+
try {
|
|
214
|
+
if ((await fsp.lstat(current)).isSymbolicLink())
|
|
215
|
+
throw createInvalidPathError();
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (error.code === "ENOENT")
|
|
218
|
+
break;
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
111
221
|
}
|
|
112
|
-
return
|
|
222
|
+
return target;
|
|
223
|
+
}
|
|
224
|
+
function logOperation(category, operation, payload) {
|
|
225
|
+
logStorageOperation(logger, config?.operationLog, category, operation, payload);
|
|
113
226
|
}
|
|
114
227
|
const file = {
|
|
115
228
|
async put(key, data, options = {}) {
|
|
@@ -119,13 +232,24 @@ function createLocalProvider() {
|
|
|
119
232
|
storageM("storage_metaFileAccess", { params: { key } })
|
|
120
233
|
);
|
|
121
234
|
}
|
|
235
|
+
let temporaryPath;
|
|
122
236
|
try {
|
|
123
|
-
const filePath = fullPath(key);
|
|
124
|
-
const
|
|
125
|
-
|
|
237
|
+
const filePath = await fullPath(key);
|
|
238
|
+
const metaPath = await fullPath(`${key}.meta.json`);
|
|
239
|
+
logOperation("write", "file.put", { key, options });
|
|
126
240
|
const dir2 = path.dirname(filePath);
|
|
127
241
|
await fsp.mkdir(dir2, { recursive: true, mode: getConfig().directoryMode });
|
|
128
|
-
|
|
242
|
+
temporaryPath = `${filePath}.${crypto.randomUUID()}.upload.tmp`;
|
|
243
|
+
await pipeline(
|
|
244
|
+
Readable.from(toByteStream(data, options.signal), { objectMode: false }),
|
|
245
|
+
fs.createWriteStream(temporaryPath, { flags: "wx", mode: getConfig().fileMode }),
|
|
246
|
+
{ signal: options.signal }
|
|
247
|
+
);
|
|
248
|
+
options.signal?.throwIfAborted();
|
|
249
|
+
if (options.ifNoneMatch === "*")
|
|
250
|
+
await fsp.link(temporaryPath, filePath);
|
|
251
|
+
else
|
|
252
|
+
await fsp.rename(temporaryPath, filePath);
|
|
129
253
|
const stat2 = await fsp.stat(filePath);
|
|
130
254
|
const metadata = {
|
|
131
255
|
key,
|
|
@@ -135,20 +259,38 @@ function createLocalProvider() {
|
|
|
135
259
|
etag: calculateEtag(stat2),
|
|
136
260
|
metadata: options.metadata
|
|
137
261
|
};
|
|
138
|
-
if (options.metadata) {
|
|
139
|
-
const metaPath = `${filePath}.meta.json`;
|
|
262
|
+
if (options.metadata || options.contentType) {
|
|
140
263
|
await fsp.writeFile(metaPath, JSON.stringify({
|
|
141
264
|
contentType: options.contentType,
|
|
142
265
|
metadata: options.metadata
|
|
143
266
|
}));
|
|
267
|
+
} else {
|
|
268
|
+
await fsp.rm(metaPath, { force: true });
|
|
144
269
|
}
|
|
145
270
|
return ok(metadata);
|
|
146
271
|
} catch (error) {
|
|
147
272
|
logger.warn("Put file failed", { key, error });
|
|
148
273
|
return err(toStorageError(error, key));
|
|
274
|
+
} finally {
|
|
275
|
+
if (temporaryPath)
|
|
276
|
+
await fsp.unlink(temporaryPath).catch(() => {
|
|
277
|
+
});
|
|
149
278
|
}
|
|
150
279
|
},
|
|
151
280
|
async get(key, options = {}) {
|
|
281
|
+
const streamed = await file.getStream(key, options);
|
|
282
|
+
if (!streamed.success)
|
|
283
|
+
return streamed;
|
|
284
|
+
try {
|
|
285
|
+
const chunks = [];
|
|
286
|
+
for await (const chunk of streamed.data)
|
|
287
|
+
chunks.push(chunk);
|
|
288
|
+
return ok(Buffer.concat(chunks));
|
|
289
|
+
} catch (error) {
|
|
290
|
+
return err(toStorageError(error, key));
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
async getStream(key, options = {}) {
|
|
152
294
|
if (isMetaFile(key)) {
|
|
153
295
|
return err(
|
|
154
296
|
HaiStorageError.INVALID_PATH,
|
|
@@ -156,21 +298,18 @@ function createLocalProvider() {
|
|
|
156
298
|
);
|
|
157
299
|
}
|
|
158
300
|
try {
|
|
159
|
-
const filePath = fullPath(key);
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
return
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
stream.on("error", (error) => resolve2(err(toStorageError(error, key))));
|
|
170
|
-
});
|
|
301
|
+
const filePath = await fullPath(key);
|
|
302
|
+
logOperation("read", "file.getStream", { key, options });
|
|
303
|
+
options.signal?.throwIfAborted();
|
|
304
|
+
const stat2 = await fsp.stat(filePath);
|
|
305
|
+
if (stat2.isDirectory())
|
|
306
|
+
return err(HaiStorageError.INVALID_PATH, storageM("storage_pathIsDir", { params: { key } }));
|
|
307
|
+
async function* read() {
|
|
308
|
+
const stream = fs.createReadStream(filePath, { start: options.rangeStart, end: options.rangeEnd, signal: options.signal });
|
|
309
|
+
for await (const chunk of stream)
|
|
310
|
+
yield chunk;
|
|
171
311
|
}
|
|
172
|
-
|
|
173
|
-
return ok(data);
|
|
312
|
+
return ok(read());
|
|
174
313
|
} catch (error) {
|
|
175
314
|
return err(toStorageError(error, key));
|
|
176
315
|
}
|
|
@@ -183,7 +322,8 @@ function createLocalProvider() {
|
|
|
183
322
|
);
|
|
184
323
|
}
|
|
185
324
|
try {
|
|
186
|
-
const filePath = fullPath(key);
|
|
325
|
+
const filePath = await fullPath(key);
|
|
326
|
+
logOperation("read", "file.head", { key });
|
|
187
327
|
const stat2 = await fsp.stat(filePath);
|
|
188
328
|
if (stat2.isDirectory()) {
|
|
189
329
|
return err(
|
|
@@ -193,7 +333,7 @@ function createLocalProvider() {
|
|
|
193
333
|
}
|
|
194
334
|
let customMetadata = {};
|
|
195
335
|
try {
|
|
196
|
-
const metaPath = `${
|
|
336
|
+
const metaPath = await fullPath(`${key}.meta.json`);
|
|
197
337
|
const metaContent = await fsp.readFile(metaPath, "utf-8");
|
|
198
338
|
customMetadata = JSON.parse(metaContent);
|
|
199
339
|
} catch {
|
|
@@ -219,7 +359,8 @@ function createLocalProvider() {
|
|
|
219
359
|
);
|
|
220
360
|
}
|
|
221
361
|
try {
|
|
222
|
-
const filePath = fullPath(key);
|
|
362
|
+
const filePath = await fullPath(key);
|
|
363
|
+
logOperation("read", "file.exists", { key });
|
|
223
364
|
await fsp.access(filePath, fs.constants.F_OK);
|
|
224
365
|
return ok(true);
|
|
225
366
|
} catch (error) {
|
|
@@ -237,7 +378,8 @@ function createLocalProvider() {
|
|
|
237
378
|
);
|
|
238
379
|
}
|
|
239
380
|
try {
|
|
240
|
-
const filePath = fullPath(key);
|
|
381
|
+
const filePath = await fullPath(key);
|
|
382
|
+
logOperation("write", "file.delete", { key });
|
|
241
383
|
logger.debug("Deleting file", { key });
|
|
242
384
|
await fsp.unlink(filePath);
|
|
243
385
|
try {
|
|
@@ -254,104 +396,121 @@ function createLocalProvider() {
|
|
|
254
396
|
}
|
|
255
397
|
},
|
|
256
398
|
async deleteMany(keys) {
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
399
|
+
logOperation("write", "file.deleteMany", { count: keys.length, keys });
|
|
400
|
+
const summary = { deletedKeys: [], failures: [] };
|
|
401
|
+
const uniqueKeys = [...new Set(keys)];
|
|
402
|
+
for (let offset = 0; offset < uniqueKeys.length; offset += 32) {
|
|
403
|
+
const batch = uniqueKeys.slice(offset, offset + 32);
|
|
404
|
+
const results = await Promise.allSettled(batch.map((key) => file.delete(key)));
|
|
405
|
+
for (const [index, result] of results.entries()) {
|
|
406
|
+
const key = batch[index];
|
|
407
|
+
if (result.status === "rejected")
|
|
408
|
+
summary.failures.push({ key, code: "UnexpectedDeleteFailure", retryable: false, outcome: "failed" });
|
|
409
|
+
else if (!result.value.success)
|
|
410
|
+
summary.failures.push({ key, code: String(result.value.error.code), retryable: false, outcome: "failed" });
|
|
411
|
+
else
|
|
412
|
+
summary.deletedKeys.push(key);
|
|
262
413
|
}
|
|
263
414
|
}
|
|
264
|
-
|
|
265
|
-
return err(
|
|
266
|
-
HaiStorageError.OPERATION_FAILED,
|
|
267
|
-
storageM("storage_deleteManyFailed", { params: { count: errors.length } }),
|
|
268
|
-
errors
|
|
269
|
-
);
|
|
270
|
-
}
|
|
271
|
-
return ok(void 0);
|
|
415
|
+
return toStorageDeleteResult(summary);
|
|
272
416
|
},
|
|
273
417
|
async copy(sourceKey, destKey, options = {}) {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
await fsp.mkdir(path.dirname(destPath), { recursive: true, mode: getConfig().directoryMode });
|
|
286
|
-
await fsp.copyFile(sourcePath, destPath);
|
|
287
|
-
const stat2 = await fsp.stat(destPath);
|
|
288
|
-
const metadata = {
|
|
289
|
-
key: destKey,
|
|
290
|
-
size: stat2.size,
|
|
291
|
-
contentType: options.contentType || getMimeType2(destKey),
|
|
292
|
-
lastModified: stat2.mtime,
|
|
293
|
-
etag: calculateEtag(stat2),
|
|
294
|
-
metadata: options.metadata
|
|
295
|
-
};
|
|
296
|
-
if (options.contentType || options.metadata) {
|
|
297
|
-
const metaPath = `${destPath}.meta.json`;
|
|
298
|
-
await fsp.writeFile(metaPath, JSON.stringify({
|
|
299
|
-
contentType: options.contentType,
|
|
300
|
-
metadata: options.metadata
|
|
301
|
-
}));
|
|
302
|
-
}
|
|
303
|
-
return ok(metadata);
|
|
304
|
-
} catch (error) {
|
|
305
|
-
return err(toStorageError(error, sourceKey));
|
|
306
|
-
}
|
|
418
|
+
logOperation("write", "file.copy", { sourceKey, destKey, options });
|
|
419
|
+
const source = await file.head(sourceKey);
|
|
420
|
+
if (!source.success)
|
|
421
|
+
return source;
|
|
422
|
+
const stream = await file.getStream(sourceKey);
|
|
423
|
+
if (!stream.success)
|
|
424
|
+
return stream;
|
|
425
|
+
return file.put(destKey, stream.data, {
|
|
426
|
+
contentType: options.contentType ?? source.data.contentType,
|
|
427
|
+
metadata: options.metadata ?? source.data.metadata
|
|
428
|
+
});
|
|
307
429
|
}
|
|
308
430
|
};
|
|
309
431
|
const dir = {
|
|
310
432
|
async list(options = {}) {
|
|
311
433
|
try {
|
|
312
434
|
const cfg = getConfig();
|
|
313
|
-
const prefix = options.prefix
|
|
314
|
-
const delimiter = options.delimiter
|
|
315
|
-
const maxKeys = options.maxKeys
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
435
|
+
const prefix = options.prefix === void 0 ? "" : options.prefix;
|
|
436
|
+
const delimiter = options.delimiter === void 0 ? "" : options.delimiter;
|
|
437
|
+
const maxKeys = options.maxKeys === void 0 ? 1e3 : options.maxKeys;
|
|
438
|
+
if (typeof prefix !== "string" || typeof delimiter !== "string" || !Number.isInteger(maxKeys) || maxKeys < 1 || maxKeys > 1e3 || Buffer.from(prefix).toString("utf8") !== prefix || Buffer.from(delimiter).toString("utf8") !== delimiter) {
|
|
439
|
+
return err(HaiStorageError.INVALID_LIST_OPTIONS, storageM("storage_listOptionsInvalid"));
|
|
440
|
+
}
|
|
441
|
+
logOperation("read", "dir.list", { options });
|
|
442
|
+
safePath(cfg.root, prefix);
|
|
443
|
+
const root = await fsp.realpath(cfg.root);
|
|
444
|
+
const scope = crypto.createHash("sha256").update(JSON.stringify([root, prefix, delimiter])).digest("hex");
|
|
445
|
+
let after = "";
|
|
446
|
+
if (options.continuationToken !== void 0) {
|
|
447
|
+
const token = options.continuationToken;
|
|
321
448
|
try {
|
|
322
|
-
|
|
449
|
+
if (typeof token !== "string" || token.length === 0 || token.length > 262144)
|
|
450
|
+
throw new Error("Invalid cursor encoding");
|
|
451
|
+
const bytes = Buffer.from(token, "base64url");
|
|
452
|
+
if (bytes.toString("base64url") !== token)
|
|
453
|
+
throw new Error("Invalid cursor encoding");
|
|
454
|
+
const decoded = JSON.parse(bytes.toString("utf8"));
|
|
455
|
+
if (!Array.isArray(decoded) || decoded.length !== 3 || decoded[0] !== 1 || decoded[1] !== scope || typeof decoded[2] !== "string" || decoded[2].length === 0 || !decoded[2].startsWith(prefix)) {
|
|
456
|
+
throw new Error("Invalid cursor scope");
|
|
457
|
+
}
|
|
458
|
+
after = decoded[2];
|
|
323
459
|
} catch {
|
|
324
|
-
return;
|
|
460
|
+
return err(HaiStorageError.INVALID_LIST_OPTIONS, storageM("storage_listCursorInvalid"));
|
|
325
461
|
}
|
|
462
|
+
}
|
|
463
|
+
const files = [];
|
|
464
|
+
const commonPrefixes = [];
|
|
465
|
+
const compare = (left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right));
|
|
466
|
+
async function* readDir(dirPath, directoryKey) {
|
|
467
|
+
const entries = await fsp.readdir(dirPath, { withFileTypes: true });
|
|
468
|
+
entries.sort((left, right) => compare(left.name + (left.isDirectory() ? "/" : ""), right.name + (right.isDirectory() ? "/" : "")));
|
|
326
469
|
for (const entry of entries) {
|
|
327
|
-
if (
|
|
328
|
-
return;
|
|
329
|
-
const fullEntryPath = path.join(dirPath, entry.name);
|
|
330
|
-
const key = path.relative(cfg.root, fullEntryPath).replace(/\\/g, "/");
|
|
331
|
-
if (entry.name.endsWith(".meta.json"))
|
|
470
|
+
if (isMetaFile(entry.name))
|
|
332
471
|
continue;
|
|
472
|
+
const key = directoryKey + entry.name;
|
|
473
|
+
const filePath = path.join(dirPath, entry.name);
|
|
333
474
|
if (entry.isDirectory()) {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
await readDir(fullEntryPath);
|
|
475
|
+
const childPrefix = `${key}/`;
|
|
476
|
+
if (prefix.startsWith(childPrefix) || childPrefix.startsWith(prefix)) {
|
|
477
|
+
yield* readDir(filePath, childPrefix);
|
|
338
478
|
}
|
|
339
|
-
} else if (entry.isFile()) {
|
|
340
|
-
|
|
341
|
-
files.push({
|
|
342
|
-
key,
|
|
343
|
-
size: stat2.size,
|
|
344
|
-
contentType: getMimeType2(key),
|
|
345
|
-
lastModified: stat2.mtime
|
|
346
|
-
});
|
|
479
|
+
} else if (entry.isFile() && key.startsWith(prefix)) {
|
|
480
|
+
yield { key, filePath };
|
|
347
481
|
}
|
|
348
482
|
}
|
|
349
483
|
}
|
|
350
|
-
|
|
484
|
+
let previous = "";
|
|
485
|
+
let last = "";
|
|
486
|
+
let count = 0;
|
|
487
|
+
let isTruncated = false;
|
|
488
|
+
for await (const entry of readDir(root, "")) {
|
|
489
|
+
const index = delimiter ? entry.key.indexOf(delimiter, prefix.length) : -1;
|
|
490
|
+
const logicalKey = index < 0 ? entry.key : entry.key.slice(0, index + delimiter.length);
|
|
491
|
+
if (logicalKey === previous)
|
|
492
|
+
continue;
|
|
493
|
+
previous = logicalKey;
|
|
494
|
+
if (compare(logicalKey, after) <= 0)
|
|
495
|
+
continue;
|
|
496
|
+
if (count === maxKeys) {
|
|
497
|
+
isTruncated = true;
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
if (index >= 0) {
|
|
501
|
+
commonPrefixes.push(logicalKey);
|
|
502
|
+
} else {
|
|
503
|
+
const stat2 = await fsp.stat(entry.filePath);
|
|
504
|
+
files.push({ key: entry.key, size: stat2.size, contentType: getMimeType2(entry.key), lastModified: stat2.mtime });
|
|
505
|
+
}
|
|
506
|
+
count++;
|
|
507
|
+
last = logicalKey;
|
|
508
|
+
}
|
|
351
509
|
return ok({
|
|
352
510
|
files,
|
|
353
|
-
commonPrefixes
|
|
354
|
-
isTruncated
|
|
511
|
+
commonPrefixes,
|
|
512
|
+
isTruncated,
|
|
513
|
+
...isTruncated ? { nextContinuationToken: Buffer.from(JSON.stringify([1, scope, last])).toString("base64url") } : {}
|
|
355
514
|
});
|
|
356
515
|
} catch (error) {
|
|
357
516
|
return err(toStorageError(error));
|
|
@@ -359,7 +518,8 @@ function createLocalProvider() {
|
|
|
359
518
|
},
|
|
360
519
|
async delete(prefix) {
|
|
361
520
|
try {
|
|
362
|
-
const dirPath = fullPath(prefix);
|
|
521
|
+
const dirPath = await fullPath(prefix);
|
|
522
|
+
logOperation("write", "dir.delete", { prefix });
|
|
363
523
|
await fsp.rm(dirPath, { recursive: true, force: true });
|
|
364
524
|
return ok(void 0);
|
|
365
525
|
} catch (error) {
|
|
@@ -372,18 +532,15 @@ function createLocalProvider() {
|
|
|
372
532
|
};
|
|
373
533
|
const presign = {
|
|
374
534
|
async getUrl(key, options) {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const signature = crypto.createHash("sha256").update(`${key}:${expires}`).digest("hex").slice(0, 16);
|
|
378
|
-
return ok(`local://${key}?expires=${expires}&signature=${signature}`);
|
|
535
|
+
logOperation("read", "presign.getUrl", { key, options });
|
|
536
|
+
return err(HaiStorageError.UNSUPPORTED_OPERATION, storageM("storage_localPresignUnsupported"));
|
|
379
537
|
},
|
|
380
538
|
async putUrl(key, options) {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
const signature = crypto.createHash("sha256").update(`put:${key}:${expires}`).digest("hex").slice(0, 16);
|
|
384
|
-
return ok(`local://${key}?action=put&expires=${expires}&signature=${signature}`);
|
|
539
|
+
logOperation("write", "presign.putUrl", { key, options });
|
|
540
|
+
return err(HaiStorageError.UNSUPPORTED_OPERATION, storageM("storage_localPresignUnsupported"));
|
|
385
541
|
},
|
|
386
|
-
publicUrl(
|
|
542
|
+
publicUrl(key) {
|
|
543
|
+
logOperation("read", "presign.publicUrl", { key });
|
|
387
544
|
return null;
|
|
388
545
|
}
|
|
389
546
|
};
|
|
@@ -403,6 +560,7 @@ function createLocalProvider() {
|
|
|
403
560
|
config = cfg;
|
|
404
561
|
logger.info("Connecting local provider", { root: cfg.root });
|
|
405
562
|
await fsp.mkdir(cfg.root, { recursive: true, mode: cfg.directoryMode });
|
|
563
|
+
config = { ...cfg, root: await fsp.realpath(cfg.root) };
|
|
406
564
|
connected = true;
|
|
407
565
|
logger.info("Local provider connected", { root: cfg.root });
|
|
408
566
|
return ok(void 0);
|
|
@@ -423,20 +581,14 @@ function createLocalProvider() {
|
|
|
423
581
|
};
|
|
424
582
|
}
|
|
425
583
|
var logger2 = core.logger.child({ module: "storage", scope: "provider-s3" });
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
parsed.username = "***";
|
|
431
|
-
if (parsed.password)
|
|
432
|
-
parsed.password = "***";
|
|
433
|
-
return parsed.toString();
|
|
434
|
-
} catch {
|
|
435
|
-
return "(invalid url)";
|
|
436
|
-
}
|
|
437
|
-
}
|
|
584
|
+
var TRAILING_SLASHES_REGEX = /\/+$/;
|
|
585
|
+
var LEADING_SLASHES_REGEX = /^\/+/;
|
|
586
|
+
var DELETE_BATCH_SIZE = 1e3;
|
|
587
|
+
var RETRYABLE_DELETE_CODES = /* @__PURE__ */ new Set(["InternalError", "ServiceUnavailable", "SlowDown", "RequestTimeout"]);
|
|
438
588
|
function toStorageError2(error, key) {
|
|
439
589
|
const e = error;
|
|
590
|
+
if (e.name === "PreconditionFailed" || e.$metadata?.httpStatusCode === 412)
|
|
591
|
+
return { ...HaiStorageError.ALREADY_EXISTS, message: storageM("storage_fileAlreadyExists", { params: { key: key ?? "" } }), cause: error };
|
|
440
592
|
if (e.name === "NoSuchKey" || e.Code === "NoSuchKey" || e.name === "NotFound" || e.Code === "NotFound" || e.name === "404" || e.$metadata?.httpStatusCode === 404) {
|
|
441
593
|
return {
|
|
442
594
|
...HaiStorageError.NOT_FOUND,
|
|
@@ -474,12 +626,12 @@ function toStorageError2(error, key) {
|
|
|
474
626
|
function withPrefix(key, prefix) {
|
|
475
627
|
if (!prefix)
|
|
476
628
|
return key;
|
|
477
|
-
return `${prefix.replace(
|
|
629
|
+
return `${prefix.replace(TRAILING_SLASHES_REGEX, "")}/${key.replace(LEADING_SLASHES_REGEX, "")}`;
|
|
478
630
|
}
|
|
479
631
|
function withoutPrefix(key, prefix) {
|
|
480
632
|
if (!prefix)
|
|
481
633
|
return key;
|
|
482
|
-
const normalizedPrefix = `${prefix.replace(
|
|
634
|
+
const normalizedPrefix = `${prefix.replace(TRAILING_SLASHES_REGEX, "")}/`;
|
|
483
635
|
if (key.startsWith(normalizedPrefix)) {
|
|
484
636
|
return key.slice(normalizedPrefix.length);
|
|
485
637
|
}
|
|
@@ -487,15 +639,57 @@ function withoutPrefix(key, prefix) {
|
|
|
487
639
|
}
|
|
488
640
|
function createS3Provider() {
|
|
489
641
|
let client = null;
|
|
642
|
+
let deleteClient = null;
|
|
643
|
+
let presignClient = null;
|
|
644
|
+
let publicBasePath = "";
|
|
490
645
|
let config = null;
|
|
491
|
-
|
|
492
|
-
|
|
646
|
+
let ready = false;
|
|
647
|
+
let closing = null;
|
|
648
|
+
function closeClients() {
|
|
649
|
+
ready = false;
|
|
650
|
+
config = null;
|
|
651
|
+
publicBasePath = "";
|
|
652
|
+
if (closing)
|
|
653
|
+
return closing;
|
|
654
|
+
closing = Promise.resolve().then(() => {
|
|
655
|
+
const failures = [];
|
|
656
|
+
for (const kind of ["normal", "delete", "presign"]) {
|
|
657
|
+
const owned = kind === "normal" ? client : kind === "delete" ? deleteClient : presignClient;
|
|
658
|
+
if (!owned)
|
|
659
|
+
continue;
|
|
660
|
+
try {
|
|
661
|
+
owned.destroy();
|
|
662
|
+
if (kind === "normal")
|
|
663
|
+
client = null;
|
|
664
|
+
else if (kind === "delete")
|
|
665
|
+
deleteClient = null;
|
|
666
|
+
else presignClient = null;
|
|
667
|
+
} catch (error) {
|
|
668
|
+
failures.push(error);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (failures.length > 0)
|
|
672
|
+
throw new AggregateError(failures, "S3 client cleanup failed");
|
|
673
|
+
}).finally(() => {
|
|
674
|
+
closing = null;
|
|
675
|
+
});
|
|
676
|
+
return closing;
|
|
677
|
+
}
|
|
678
|
+
function getClient(operation) {
|
|
679
|
+
const selected = operation === "deleteMany" ? deleteClient : operation === "presign" ? presignClient ?? client : client;
|
|
680
|
+
if (!ready || !selected) {
|
|
493
681
|
throw new Error(storageM("storage_s3ClientNotInitialized"));
|
|
494
682
|
}
|
|
495
|
-
return
|
|
683
|
+
return selected;
|
|
684
|
+
}
|
|
685
|
+
function publicPresignedUrl(url, basePath) {
|
|
686
|
+
if (!basePath)
|
|
687
|
+
return url;
|
|
688
|
+
const pathOffset = url.indexOf("/", url.indexOf("://") + 3);
|
|
689
|
+
return `${url.slice(0, pathOffset)}${basePath}${url.slice(pathOffset)}`;
|
|
496
690
|
}
|
|
497
691
|
function getConfig() {
|
|
498
|
-
if (!config) {
|
|
692
|
+
if (!ready || !config) {
|
|
499
693
|
throw new Error(storageM("storage_s3ConfigNotInitialized"));
|
|
500
694
|
}
|
|
501
695
|
return config;
|
|
@@ -503,29 +697,59 @@ function createS3Provider() {
|
|
|
503
697
|
function fullKey(key) {
|
|
504
698
|
return withPrefix(key, getConfig().prefix);
|
|
505
699
|
}
|
|
700
|
+
function logOperation(category, operation, payload) {
|
|
701
|
+
logStorageOperation(logger2, config?.operationLog, category, operation, payload);
|
|
702
|
+
}
|
|
506
703
|
const file = {
|
|
507
704
|
async put(key, data, options = {}) {
|
|
508
705
|
try {
|
|
509
706
|
const s3Client = getClient();
|
|
510
707
|
const s3Config = getConfig();
|
|
511
708
|
const fullPath = fullKey(key);
|
|
709
|
+
options.signal?.throwIfAborted();
|
|
512
710
|
const body = typeof data === "string" ? Buffer.from(data) : data;
|
|
513
|
-
|
|
711
|
+
const buffered = body instanceof Uint8Array;
|
|
712
|
+
let size = buffered ? body.byteLength : 0;
|
|
713
|
+
const params = {
|
|
514
714
|
Bucket: s3Config.bucket,
|
|
515
715
|
Key: fullPath,
|
|
516
|
-
Body: body,
|
|
517
716
|
ContentType: options.contentType,
|
|
518
717
|
Metadata: options.metadata,
|
|
519
718
|
CacheControl: options.cacheControl,
|
|
520
|
-
ContentDisposition: options.contentDisposition
|
|
521
|
-
|
|
719
|
+
ContentDisposition: options.contentDisposition,
|
|
720
|
+
IfNoneMatch: options.ifNoneMatch
|
|
721
|
+
};
|
|
722
|
+
logOperation("write", "file.put", { key, fullPath, byteLength: buffered ? size : void 0, options });
|
|
723
|
+
if (buffered) {
|
|
724
|
+
await s3Client.send(new PutObjectCommand({ ...params, Body: body }), { abortSignal: options.signal });
|
|
725
|
+
} else {
|
|
726
|
+
async function* counted() {
|
|
727
|
+
for await (const chunk of toByteStream(data, options.signal)) {
|
|
728
|
+
size += chunk.byteLength;
|
|
729
|
+
yield chunk;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
const stream = Readable.from(counted(), { objectMode: false });
|
|
733
|
+
const controller = new AbortController();
|
|
734
|
+
const abort = () => controller.abort();
|
|
735
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
736
|
+
try {
|
|
737
|
+
if (options.signal?.aborted)
|
|
738
|
+
abort();
|
|
739
|
+
const { Upload } = await import('@aws-sdk/lib-storage');
|
|
740
|
+
await new Upload({ client: s3Client, params: { ...params, Body: stream }, queueSize: 2, leavePartsOnError: false, abortController: controller }).done();
|
|
741
|
+
} finally {
|
|
742
|
+
options.signal?.removeEventListener("abort", abort);
|
|
743
|
+
stream.destroy();
|
|
744
|
+
}
|
|
745
|
+
}
|
|
522
746
|
const headResult = await file.head(key);
|
|
523
747
|
if (headResult.success) {
|
|
524
748
|
return headResult;
|
|
525
749
|
}
|
|
526
750
|
return ok({
|
|
527
751
|
key,
|
|
528
|
-
size
|
|
752
|
+
size,
|
|
529
753
|
contentType: options.contentType || "application/octet-stream",
|
|
530
754
|
lastModified: /* @__PURE__ */ new Date()
|
|
531
755
|
});
|
|
@@ -534,10 +758,24 @@ function createS3Provider() {
|
|
|
534
758
|
}
|
|
535
759
|
},
|
|
536
760
|
async get(key, options = {}) {
|
|
761
|
+
const streamed = await file.getStream(key, options);
|
|
762
|
+
if (!streamed.success)
|
|
763
|
+
return streamed;
|
|
764
|
+
try {
|
|
765
|
+
const chunks = [];
|
|
766
|
+
for await (const chunk of streamed.data)
|
|
767
|
+
chunks.push(chunk);
|
|
768
|
+
return ok(Buffer.concat(chunks));
|
|
769
|
+
} catch (error) {
|
|
770
|
+
return err(toStorageError2(error, key));
|
|
771
|
+
}
|
|
772
|
+
},
|
|
773
|
+
async getStream(key, options = {}) {
|
|
537
774
|
try {
|
|
538
775
|
const s3Client = getClient();
|
|
539
776
|
const s3Config = getConfig();
|
|
540
777
|
const fullPath = fullKey(key);
|
|
778
|
+
logOperation("read", "file.getStream", { key, fullPath, options });
|
|
541
779
|
let Range;
|
|
542
780
|
if (options.rangeStart !== void 0 || options.rangeEnd !== void 0) {
|
|
543
781
|
const start = options.rangeStart ?? 0;
|
|
@@ -548,19 +786,15 @@ function createS3Provider() {
|
|
|
548
786
|
Bucket: s3Config.bucket,
|
|
549
787
|
Key: fullPath,
|
|
550
788
|
Range
|
|
551
|
-
}));
|
|
789
|
+
}), { abortSignal: options.signal });
|
|
552
790
|
if (!response.Body) {
|
|
553
791
|
return err(
|
|
554
792
|
HaiStorageError.OPERATION_FAILED,
|
|
555
793
|
storageM("storage_responseBodyEmpty")
|
|
556
794
|
);
|
|
557
795
|
}
|
|
558
|
-
const chunks = [];
|
|
559
796
|
const stream = response.Body;
|
|
560
|
-
|
|
561
|
-
chunks.push(chunk);
|
|
562
|
-
}
|
|
563
|
-
return ok(Buffer.concat(chunks));
|
|
797
|
+
return ok(stream);
|
|
564
798
|
} catch (error) {
|
|
565
799
|
return err(toStorageError2(error, key));
|
|
566
800
|
}
|
|
@@ -570,6 +804,7 @@ function createS3Provider() {
|
|
|
570
804
|
const s3Client = getClient();
|
|
571
805
|
const s3Config = getConfig();
|
|
572
806
|
const fullPath = fullKey(key);
|
|
807
|
+
logOperation("read", "file.head", { key, fullPath });
|
|
573
808
|
const response = await s3Client.send(new HeadObjectCommand({
|
|
574
809
|
Bucket: s3Config.bucket,
|
|
575
810
|
Key: fullPath
|
|
@@ -587,6 +822,7 @@ function createS3Provider() {
|
|
|
587
822
|
}
|
|
588
823
|
},
|
|
589
824
|
async exists(key) {
|
|
825
|
+
logOperation("read", "file.exists", { key });
|
|
590
826
|
const result = await file.head(key);
|
|
591
827
|
if (result.success) {
|
|
592
828
|
return ok(true);
|
|
@@ -601,6 +837,7 @@ function createS3Provider() {
|
|
|
601
837
|
const s3Client = getClient();
|
|
602
838
|
const s3Config = getConfig();
|
|
603
839
|
const fullPath = fullKey(key);
|
|
840
|
+
logOperation("write", "file.delete", { key, fullPath });
|
|
604
841
|
await s3Client.send(new DeleteObjectCommand({
|
|
605
842
|
Bucket: s3Config.bucket,
|
|
606
843
|
Key: fullPath
|
|
@@ -612,18 +849,65 @@ function createS3Provider() {
|
|
|
612
849
|
},
|
|
613
850
|
async deleteMany(keys) {
|
|
614
851
|
try {
|
|
615
|
-
const s3Client = getClient();
|
|
852
|
+
const s3Client = getClient("deleteMany");
|
|
616
853
|
const s3Config = getConfig();
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
854
|
+
logOperation("write", "file.deleteMany", { count: keys.length, keys });
|
|
855
|
+
const uniqueKeys = [...new Set(keys)];
|
|
856
|
+
const summary = { deletedKeys: [], failures: [] };
|
|
857
|
+
for (let offset = 0; offset < uniqueKeys.length; offset += DELETE_BATCH_SIZE) {
|
|
858
|
+
let pending = uniqueKeys.slice(offset, offset + DELETE_BATCH_SIZE);
|
|
859
|
+
for (let attempt = 0; pending.length > 0 && attempt < 3; attempt++) {
|
|
860
|
+
let response;
|
|
861
|
+
try {
|
|
862
|
+
response = await s3Client.send(new DeleteObjectsCommand({
|
|
863
|
+
Bucket: s3Config.bucket,
|
|
864
|
+
Delete: { Objects: [...new Set(pending.map(fullKey))].map((Key) => ({ Key })), Quiet: false }
|
|
865
|
+
}));
|
|
866
|
+
} catch (cause) {
|
|
867
|
+
const failure = cause && typeof cause === "object" ? cause : {};
|
|
868
|
+
const status = failure.$metadata?.httpStatusCode;
|
|
869
|
+
const code = failure.name && /^[\w.-]{1,80}$/.test(failure.name) ? failure.name : "RequestFailed";
|
|
870
|
+
const retryable = status === void 0 || status === 408 || status === 429 || status >= 500;
|
|
871
|
+
const outcome = status !== void 0 && status >= 400 && status < 500 && status !== 408 ? "failed" : "unknown";
|
|
872
|
+
summary.failures.push(...pending.map((key) => ({ key, code, retryable, outcome })));
|
|
873
|
+
summary.failures.push(...uniqueKeys.slice(offset + DELETE_BATCH_SIZE).map((key) => ({ key, code, retryable, outcome: "notAttempted" })));
|
|
874
|
+
return toStorageDeleteResult(summary);
|
|
875
|
+
}
|
|
876
|
+
const outcomes = /* @__PURE__ */ new Map();
|
|
877
|
+
const requested = new Set(pending.map(fullKey));
|
|
878
|
+
let malformed = false;
|
|
879
|
+
const items = [
|
|
880
|
+
...(response.Deleted ?? []).map((item) => ({ key: item.Key, code: null })),
|
|
881
|
+
...(response.Errors ?? []).map((item) => ({ key: item.Key, code: item.Code && /^[\w.-]{1,80}$/.test(item.Code) ? item.Code : "UnknownError" }))
|
|
882
|
+
];
|
|
883
|
+
for (const item of items) {
|
|
884
|
+
if (!item.key || !requested.has(item.key) || outcomes.has(item.key)) {
|
|
885
|
+
malformed = true;
|
|
886
|
+
break;
|
|
887
|
+
}
|
|
888
|
+
outcomes.set(item.key, item.code);
|
|
889
|
+
}
|
|
890
|
+
if (malformed || outcomes.size !== requested.size) {
|
|
891
|
+
summary.failures.push(...pending.map((key) => ({ key, code: "InvalidDeleteResponse", retryable: false, outcome: "unknown" })));
|
|
892
|
+
summary.failures.push(...uniqueKeys.slice(offset + DELETE_BATCH_SIZE).map((key) => ({ key, code: "InvalidDeleteResponse", retryable: false, outcome: "notAttempted" })));
|
|
893
|
+
return toStorageDeleteResult(summary);
|
|
894
|
+
}
|
|
895
|
+
const retry = [];
|
|
896
|
+
for (const key of pending) {
|
|
897
|
+
const code = outcomes.get(fullKey(key));
|
|
898
|
+
if (code === null)
|
|
899
|
+
summary.deletedKeys.push(key);
|
|
900
|
+
else if (code && RETRYABLE_DELETE_CODES.has(code) && attempt < 2)
|
|
901
|
+
retry.push(key);
|
|
902
|
+
else
|
|
903
|
+
summary.failures.push({ key, code: code ?? "UnknownError", retryable: !!code && RETRYABLE_DELETE_CODES.has(code), outcome: "failed" });
|
|
904
|
+
}
|
|
905
|
+
pending = retry;
|
|
906
|
+
if (pending.length > 0)
|
|
907
|
+
await new Promise((resolve2) => setTimeout(resolve2, 100 * 2 ** attempt));
|
|
624
908
|
}
|
|
625
|
-
}
|
|
626
|
-
return
|
|
909
|
+
}
|
|
910
|
+
return toStorageDeleteResult(summary);
|
|
627
911
|
} catch (error) {
|
|
628
912
|
return err(toStorageError2(error));
|
|
629
913
|
}
|
|
@@ -634,6 +918,7 @@ function createS3Provider() {
|
|
|
634
918
|
const s3Config = getConfig();
|
|
635
919
|
const sourceFullPath = fullKey(sourceKey);
|
|
636
920
|
const destFullPath = fullKey(destKey);
|
|
921
|
+
logOperation("write", "file.copy", { sourceKey, destKey, sourceFullPath, destFullPath, options });
|
|
637
922
|
await s3Client.send(new CopyObjectCommand({
|
|
638
923
|
Bucket: s3Config.bucket,
|
|
639
924
|
CopySource: `${s3Config.bucket}/${sourceFullPath}`,
|
|
@@ -653,6 +938,7 @@ function createS3Provider() {
|
|
|
653
938
|
try {
|
|
654
939
|
const s3Client = getClient();
|
|
655
940
|
const s3Config = getConfig();
|
|
941
|
+
logOperation("read", "dir.list", { options });
|
|
656
942
|
let prefix = s3Config.prefix || "";
|
|
657
943
|
if (options.prefix) {
|
|
658
944
|
prefix = withPrefix(options.prefix, s3Config.prefix);
|
|
@@ -685,6 +971,7 @@ function createS3Provider() {
|
|
|
685
971
|
},
|
|
686
972
|
async delete(prefix) {
|
|
687
973
|
try {
|
|
974
|
+
logOperation("write", "dir.delete", { prefix });
|
|
688
975
|
let continuationToken;
|
|
689
976
|
do {
|
|
690
977
|
const listResult = await dir.list({
|
|
@@ -713,19 +1000,22 @@ function createS3Provider() {
|
|
|
713
1000
|
const presign = {
|
|
714
1001
|
async getUrl(key, options) {
|
|
715
1002
|
try {
|
|
716
|
-
const
|
|
1003
|
+
const parsed = PresignOptionsSchema.parse(options ?? {});
|
|
1004
|
+
const s3Client = getClient("presign");
|
|
717
1005
|
const s3Config = getConfig();
|
|
718
1006
|
const fullPath = fullKey(key);
|
|
1007
|
+
logOperation("read", "presign.getUrl", { key, fullPath, options });
|
|
1008
|
+
const basePath = publicBasePath;
|
|
719
1009
|
const command = new GetObjectCommand({
|
|
720
1010
|
Bucket: s3Config.bucket,
|
|
721
1011
|
Key: fullPath,
|
|
722
|
-
ResponseContentType:
|
|
723
|
-
ResponseContentDisposition:
|
|
1012
|
+
ResponseContentType: parsed.responseContentType,
|
|
1013
|
+
ResponseContentDisposition: parsed.responseContentDisposition
|
|
724
1014
|
});
|
|
725
1015
|
const url = await getSignedUrl(s3Client, command, {
|
|
726
|
-
expiresIn:
|
|
1016
|
+
expiresIn: parsed.expiresIn
|
|
727
1017
|
});
|
|
728
|
-
return ok(url);
|
|
1018
|
+
return ok(publicPresignedUrl(url, basePath));
|
|
729
1019
|
} catch (error) {
|
|
730
1020
|
return err(
|
|
731
1021
|
HaiStorageError.PRESIGN_FAILED,
|
|
@@ -736,18 +1026,21 @@ function createS3Provider() {
|
|
|
736
1026
|
},
|
|
737
1027
|
async putUrl(key, options) {
|
|
738
1028
|
try {
|
|
739
|
-
const
|
|
1029
|
+
const parsed = PresignUploadOptionsSchema.parse(options ?? {});
|
|
1030
|
+
const s3Client = getClient("presign");
|
|
740
1031
|
const s3Config = getConfig();
|
|
741
1032
|
const fullPath = fullKey(key);
|
|
1033
|
+
logOperation("write", "presign.putUrl", { key, fullPath, options });
|
|
1034
|
+
const basePath = publicBasePath;
|
|
742
1035
|
const command = new PutObjectCommand({
|
|
743
1036
|
Bucket: s3Config.bucket,
|
|
744
1037
|
Key: fullPath,
|
|
745
|
-
ContentType:
|
|
1038
|
+
ContentType: parsed.contentType
|
|
746
1039
|
});
|
|
747
1040
|
const url = await getSignedUrl(s3Client, command, {
|
|
748
|
-
expiresIn:
|
|
1041
|
+
expiresIn: parsed.expiresIn
|
|
749
1042
|
});
|
|
750
|
-
return ok(url);
|
|
1043
|
+
return ok(publicPresignedUrl(url, basePath));
|
|
751
1044
|
} catch (error) {
|
|
752
1045
|
return err(
|
|
753
1046
|
HaiStorageError.PRESIGN_FAILED,
|
|
@@ -762,7 +1055,8 @@ function createS3Provider() {
|
|
|
762
1055
|
return null;
|
|
763
1056
|
}
|
|
764
1057
|
const fullPath = fullKey(key);
|
|
765
|
-
|
|
1058
|
+
logOperation("read", "presign.publicUrl", { key, fullPath });
|
|
1059
|
+
return `${s3Config.publicUrl.replace(TRAILING_SLASHES_REGEX, "")}/${fullPath}`;
|
|
766
1060
|
}
|
|
767
1061
|
};
|
|
768
1062
|
return {
|
|
@@ -778,53 +1072,149 @@ function createS3Provider() {
|
|
|
778
1072
|
);
|
|
779
1073
|
}
|
|
780
1074
|
try {
|
|
1075
|
+
await closeClients();
|
|
781
1076
|
config = cfg;
|
|
782
1077
|
logger2.info("Connecting S3 provider", {
|
|
783
1078
|
bucket: cfg.bucket,
|
|
784
1079
|
region: cfg.region,
|
|
785
|
-
endpoint: cfg.endpoint ?
|
|
1080
|
+
endpoint: cfg.endpoint ? core.sanitize.sanitizeUrl(cfg.endpoint) : void 0
|
|
786
1081
|
});
|
|
787
|
-
|
|
1082
|
+
const clientConfig = {
|
|
788
1083
|
region: cfg.region,
|
|
789
1084
|
endpoint: cfg.endpoint,
|
|
790
1085
|
forcePathStyle: cfg.forcePathStyle,
|
|
1086
|
+
// 不强制兼容服务实现 AWS checksum 扩展;校验工具按实际字节独立计算。
|
|
1087
|
+
requestChecksumCalculation: "WHEN_REQUIRED",
|
|
1088
|
+
responseChecksumValidation: "WHEN_REQUIRED",
|
|
791
1089
|
credentials: {
|
|
792
1090
|
accessKeyId: cfg.accessKeyId,
|
|
793
1091
|
secretAccessKey: cfg.secretAccessKey
|
|
794
1092
|
}
|
|
795
|
-
}
|
|
1093
|
+
};
|
|
1094
|
+
client = new S3Client(clientConfig);
|
|
1095
|
+
deleteClient = new S3Client({ ...clientConfig, maxAttempts: 1 });
|
|
1096
|
+
if (cfg.publicEndpoint) {
|
|
1097
|
+
const endpoint = new URL(cfg.publicEndpoint);
|
|
1098
|
+
publicBasePath = endpoint.pathname.replace(TRAILING_SLASHES_REGEX, "");
|
|
1099
|
+
presignClient = new S3Client({ ...clientConfig, endpoint: endpoint.origin, forcePathStyle: publicBasePath ? true : cfg.forcePathStyle });
|
|
1100
|
+
}
|
|
1101
|
+
if (cfg.createBucket) {
|
|
1102
|
+
try {
|
|
1103
|
+
await client.send(new HeadBucketCommand({ Bucket: cfg.bucket }));
|
|
1104
|
+
} catch (error) {
|
|
1105
|
+
if (!(error && typeof error === "object" && "$metadata" in error && error.$metadata && typeof error.$metadata === "object" && "httpStatusCode" in error.$metadata && error.$metadata.httpStatusCode === 404)) {
|
|
1106
|
+
throw error;
|
|
1107
|
+
}
|
|
1108
|
+
try {
|
|
1109
|
+
await client.send(new CreateBucketCommand({
|
|
1110
|
+
Bucket: cfg.bucket,
|
|
1111
|
+
// AWS us-east-1 要省略 LocationConstraint;兼容服务可使用自定义区域名。
|
|
1112
|
+
...cfg.region === "us-east-1" ? {} : { CreateBucketConfiguration: { LocationConstraint: cfg.region } }
|
|
1113
|
+
}));
|
|
1114
|
+
logger2.info("S3 bucket created", { bucket: cfg.bucket });
|
|
1115
|
+
} catch (createError) {
|
|
1116
|
+
if (!(createError instanceof Error && createError.name === "BucketAlreadyOwnedByYou"))
|
|
1117
|
+
throw createError;
|
|
1118
|
+
await client.send(new HeadBucketCommand({ Bucket: cfg.bucket }));
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
796
1122
|
await client.send(new ListObjectsV2Command({
|
|
797
1123
|
Bucket: cfg.bucket,
|
|
798
1124
|
MaxKeys: 1
|
|
799
1125
|
}));
|
|
1126
|
+
config = cfg;
|
|
1127
|
+
ready = true;
|
|
800
1128
|
logger2.info("S3 provider connected", { bucket: cfg.bucket });
|
|
801
1129
|
return ok(void 0);
|
|
802
1130
|
} catch (error) {
|
|
803
1131
|
logger2.error("S3 provider connect failed", { error });
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
client.destroy();
|
|
812
|
-
client = null;
|
|
813
|
-
config = null;
|
|
814
|
-
logger2.info("S3 provider disconnected");
|
|
1132
|
+
const original = toStorageError2(error);
|
|
1133
|
+
try {
|
|
1134
|
+
await closeClients();
|
|
1135
|
+
return err(original);
|
|
1136
|
+
} catch (cleanupError) {
|
|
1137
|
+
return err({ ...original, ext: { ...original.ext, cleanupPending: true, cleanupError } });
|
|
1138
|
+
}
|
|
815
1139
|
}
|
|
816
1140
|
},
|
|
1141
|
+
close: closeClients,
|
|
817
1142
|
isConnected() {
|
|
818
|
-
return client !== null && config !== null;
|
|
1143
|
+
return ready && client !== null && deleteClient !== null && config !== null;
|
|
819
1144
|
}
|
|
820
1145
|
};
|
|
821
1146
|
}
|
|
1147
|
+
var HEX_CHECKSUM = /^[\da-f]+$/i;
|
|
1148
|
+
var BASE64_CHECKSUM = /^(?:[A-Z\d+/]{4})*(?:[A-Z\d+/]{2}==|[A-Z\d+/]{3}=)?$/i;
|
|
1149
|
+
var ALGORITHMS = /* @__PURE__ */ new Set(["sha256", "sha384", "sha512", "sha1", "md5"]);
|
|
1150
|
+
async function generate(data, options = {}) {
|
|
1151
|
+
try {
|
|
1152
|
+
const algorithm = options.algorithm ?? "sha256";
|
|
1153
|
+
if (!ALGORITHMS.has(algorithm))
|
|
1154
|
+
return err(HaiStorageError.INVALID_CHECKSUM, storageM("storage_checksumInvalid"));
|
|
1155
|
+
const hash = createHash(algorithm);
|
|
1156
|
+
for await (const chunk of toByteStream(data, options.signal))
|
|
1157
|
+
hash.update(chunk);
|
|
1158
|
+
return ok(hash.digest(options.encoding ?? "hex"));
|
|
1159
|
+
} catch (error) {
|
|
1160
|
+
return err(HaiStorageError.CHECKSUM_FAILED, storageM("storage_checksumFailed"), error);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
var checksum = {
|
|
1164
|
+
generate,
|
|
1165
|
+
async verify(data, expected, options = {}) {
|
|
1166
|
+
const encoding = options.encoding ?? "hex";
|
|
1167
|
+
const valid = encoding === "hex" ? expected.length % 2 === 0 && HEX_CHECKSUM.test(expected) : BASE64_CHECKSUM.test(expected) && expected.length > 0;
|
|
1168
|
+
if (!valid)
|
|
1169
|
+
return err(HaiStorageError.INVALID_CHECKSUM, storageM("storage_checksumInvalid"));
|
|
1170
|
+
const calculated = await generate(data, options);
|
|
1171
|
+
if (!calculated.success)
|
|
1172
|
+
return calculated;
|
|
1173
|
+
const actualBytes = Buffer.from(calculated.data, encoding);
|
|
1174
|
+
const expectedBytes = Buffer.from(expected, encoding);
|
|
1175
|
+
return ok(actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes));
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
822
1178
|
|
|
823
1179
|
// src/storage-main.ts
|
|
824
1180
|
var logger3 = core.logger.child({ module: "storage", scope: "main" });
|
|
825
1181
|
var currentProvider = null;
|
|
826
1182
|
var currentConfig = null;
|
|
827
1183
|
var initInProgress = false;
|
|
1184
|
+
var pendingProvider = null;
|
|
1185
|
+
var initCompletion = null;
|
|
1186
|
+
var closingPromise = null;
|
|
1187
|
+
async function closeOwnedProvider() {
|
|
1188
|
+
if (currentProvider) {
|
|
1189
|
+
pendingProvider = currentProvider;
|
|
1190
|
+
currentProvider = null;
|
|
1191
|
+
}
|
|
1192
|
+
currentConfig = null;
|
|
1193
|
+
if (!pendingProvider)
|
|
1194
|
+
return ok(void 0);
|
|
1195
|
+
try {
|
|
1196
|
+
await pendingProvider.close();
|
|
1197
|
+
pendingProvider = null;
|
|
1198
|
+
return ok(void 0);
|
|
1199
|
+
} catch (error) {
|
|
1200
|
+
logger3.error("Provider cleanup failed; retaining resource for retry", { error });
|
|
1201
|
+
return err({ ...HaiStorageError.CONNECTION_FAILED, message: storageM("storage_operationFailed", {
|
|
1202
|
+
params: { error: error instanceof Error ? error.message : String(error) }
|
|
1203
|
+
}), cause: error, ext: { cleanupPending: true } });
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
async function failInitialization(result) {
|
|
1207
|
+
const cleanup = await closeOwnedProvider();
|
|
1208
|
+
if (result.success)
|
|
1209
|
+
return result;
|
|
1210
|
+
if (cleanup.success) {
|
|
1211
|
+
const ext = { ...result.error.ext };
|
|
1212
|
+
delete ext.cleanupPending;
|
|
1213
|
+
delete ext.cleanupError;
|
|
1214
|
+
return err({ ...result.error, ext: Object.keys(ext).length > 0 ? ext : void 0 });
|
|
1215
|
+
}
|
|
1216
|
+
return err({ ...result.error, ext: { ...result.error.ext, cleanupPending: true, cleanupError: cleanup.error } });
|
|
1217
|
+
}
|
|
828
1218
|
function createProvider(config) {
|
|
829
1219
|
switch (config.type) {
|
|
830
1220
|
case "s3":
|
|
@@ -849,17 +1239,27 @@ var notInitializedPresign = new Proxy(
|
|
|
849
1239
|
}
|
|
850
1240
|
);
|
|
851
1241
|
var storage = {
|
|
1242
|
+
checksum,
|
|
852
1243
|
/**
|
|
853
1244
|
* 初始化存储连接。
|
|
854
1245
|
*
|
|
855
1246
|
* 如果当前已有活跃连接,会先自动 close 再重新初始化。
|
|
856
|
-
*
|
|
1247
|
+
* 配置通过 Zod Schema 校验;校验、连接或清理失败均返回 HaiResult,不发布失败候选。
|
|
857
1248
|
*
|
|
858
1249
|
* @param config 存储配置(S3 或本地),支持省略带默认值的字段。
|
|
859
1250
|
* @returns 成功时返回 ok(undefined);失败时返回包含错误码和消息的 err。
|
|
1251
|
+
* @example
|
|
1252
|
+
* ```ts
|
|
1253
|
+
* import { storage } from '@h-ai/storage'
|
|
1254
|
+
*
|
|
1255
|
+
* const initialized = await storage.init({ type: 'local', root: './data/uploads' })
|
|
1256
|
+
* if (!initialized.success) throw new Error(initialized.error.message)
|
|
1257
|
+
* const closed = await storage.close()
|
|
1258
|
+
* if (!closed.success) throw new Error(closed.error.message)
|
|
1259
|
+
* ```
|
|
860
1260
|
*/
|
|
861
1261
|
async init(config) {
|
|
862
|
-
if (initInProgress) {
|
|
1262
|
+
if (initInProgress || closingPromise) {
|
|
863
1263
|
logger3.warn("Storage init already in progress, skipping concurrent call");
|
|
864
1264
|
return err(
|
|
865
1265
|
HaiStorageError.OPERATION_FAILED,
|
|
@@ -867,11 +1267,14 @@ var storage = {
|
|
|
867
1267
|
);
|
|
868
1268
|
}
|
|
869
1269
|
initInProgress = true;
|
|
1270
|
+
let resolveInit;
|
|
1271
|
+
initCompletion = new Promise((resolve2) => {
|
|
1272
|
+
resolveInit = resolve2;
|
|
1273
|
+
});
|
|
870
1274
|
try {
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
}
|
|
1275
|
+
const cleanup = await closeOwnedProvider();
|
|
1276
|
+
if (!cleanup.success)
|
|
1277
|
+
return cleanup;
|
|
875
1278
|
logger3.info("Initializing storage module");
|
|
876
1279
|
const parseResult = StorageConfigSchema.safeParse(config);
|
|
877
1280
|
if (!parseResult.success) {
|
|
@@ -883,30 +1286,36 @@ var storage = {
|
|
|
883
1286
|
);
|
|
884
1287
|
}
|
|
885
1288
|
const parsed = parseResult.data;
|
|
1289
|
+
if (core.module.requiresSharedState(process.env) && parsed.type === "local")
|
|
1290
|
+
return err(HaiStorageError.CONFIG_ERROR, storageM("storage_sharedBackendRequired"));
|
|
886
1291
|
const provider = createProvider(parsed);
|
|
1292
|
+
pendingProvider = provider;
|
|
887
1293
|
const connectResult = await provider.connect(parsed);
|
|
888
1294
|
if (!connectResult.success) {
|
|
889
1295
|
logger3.error("Storage module initialization failed", {
|
|
890
1296
|
code: connectResult.error.code,
|
|
891
1297
|
message: connectResult.error.message
|
|
892
1298
|
});
|
|
893
|
-
return connectResult;
|
|
1299
|
+
return await failInitialization(connectResult);
|
|
894
1300
|
}
|
|
1301
|
+
pendingProvider = null;
|
|
895
1302
|
currentProvider = provider;
|
|
896
1303
|
currentConfig = parsed;
|
|
897
1304
|
logger3.info("Storage module initialized");
|
|
898
1305
|
return ok(void 0);
|
|
899
1306
|
} catch (error) {
|
|
900
1307
|
logger3.error("Storage module initialization failed", { error });
|
|
901
|
-
return err(
|
|
1308
|
+
return await failInitialization(err(
|
|
902
1309
|
HaiStorageError.CONNECTION_FAILED,
|
|
903
1310
|
storageM("storage_operationFailed", {
|
|
904
1311
|
params: { error: error instanceof Error ? error.message : String(error) }
|
|
905
1312
|
}),
|
|
906
1313
|
error
|
|
907
|
-
);
|
|
1314
|
+
));
|
|
908
1315
|
} finally {
|
|
909
1316
|
initInProgress = false;
|
|
1317
|
+
initCompletion = null;
|
|
1318
|
+
resolveInit();
|
|
910
1319
|
}
|
|
911
1320
|
},
|
|
912
1321
|
/** 文件操作接口。未初始化时所有方法返回 NOT_INITIALIZED 错误 */
|
|
@@ -921,36 +1330,39 @@ var storage = {
|
|
|
921
1330
|
get presign() {
|
|
922
1331
|
return currentProvider?.presign ?? notInitializedPresign;
|
|
923
1332
|
},
|
|
924
|
-
/**
|
|
1333
|
+
/** 当前脱敏后的存储配置快照;未初始化或已关闭时为 null */
|
|
925
1334
|
get config() {
|
|
926
|
-
return currentConfig;
|
|
1335
|
+
return currentConfig ? core.sanitize.sanitizeSensitiveFields(currentConfig) : null;
|
|
927
1336
|
},
|
|
928
1337
|
/** 是否已完成初始化 */
|
|
929
1338
|
get isInitialized() {
|
|
930
|
-
return currentProvider !== null;
|
|
1339
|
+
return currentProvider !== null && currentProvider.isConnected();
|
|
931
1340
|
},
|
|
932
1341
|
/**
|
|
933
1342
|
* 关闭存储连接并释放资源
|
|
934
1343
|
*
|
|
935
1344
|
* 关闭后 file/dir/presign 操作将返回 NOT_INITIALIZED 错误。
|
|
936
|
-
*
|
|
1345
|
+
* 等待初始化并合并并发关闭;失败时保留资源供 close/init 重试。
|
|
1346
|
+
* @returns 所有客户端释放成功才返回成功。
|
|
1347
|
+
* @example
|
|
1348
|
+
* ```ts
|
|
1349
|
+
* import { storage } from '@h-ai/storage'
|
|
1350
|
+
*
|
|
1351
|
+
* const closed = await storage.close()
|
|
1352
|
+
* if (!closed.success) throw new Error(closed.error.message)
|
|
1353
|
+
* ```
|
|
937
1354
|
*/
|
|
938
1355
|
async close() {
|
|
939
|
-
if (!
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
logger3.info("Storage module closed");
|
|
948
|
-
} catch (error) {
|
|
949
|
-
logger3.error("Storage module close failed", { error });
|
|
950
|
-
} finally {
|
|
951
|
-
currentProvider = null;
|
|
952
|
-
currentConfig = null;
|
|
1356
|
+
if (!closingPromise) {
|
|
1357
|
+
closingPromise = (async () => {
|
|
1358
|
+
if (initCompletion)
|
|
1359
|
+
await initCompletion;
|
|
1360
|
+
return await closeOwnedProvider();
|
|
1361
|
+
})().finally(() => {
|
|
1362
|
+
closingPromise = null;
|
|
1363
|
+
});
|
|
953
1364
|
}
|
|
1365
|
+
return await closingPromise;
|
|
954
1366
|
}
|
|
955
1367
|
};
|
|
956
1368
|
|