@h-ai/storage 0.1.0-alpha9 → 1.0.0-beta.2

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