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

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-P4NA6BCH.js';
2
+ export { HaiStorageError, LocalConfigSchema, PresignOptionsSchema, PresignUploadOptionsSchema, S3ConfigSchema, StorageConfigSchema, StorageOperationLogConfigSchema, StorageTypeSchema } from './chunk-P4NA6BCH.js';
3
+ import { storageM, MIME_TYPES, MIME_TYPE_DEFAULT } from './chunk-KVFEQHRX.js';
4
+ export { downloadAndSave, downloadWithPresignedUrl, formatFileSize, getFileExtension, getMimeType, uploadWithPresignedUrl } from './chunk-KVFEQHRX.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,49 @@ function withoutPrefix(key, prefix) {
487
644
  }
488
645
  function createS3Provider() {
489
646
  let client = null;
647
+ let deleteClient = null;
648
+ let presignClient = null;
490
649
  let config = null;
491
- function getClient() {
492
- if (!client) {
650
+ let ready = false;
651
+ let closing = null;
652
+ function closeClients() {
653
+ ready = false;
654
+ config = null;
655
+ if (closing)
656
+ return closing;
657
+ closing = Promise.resolve().then(() => {
658
+ const failures = [];
659
+ for (const kind of ["normal", "delete", "presign"]) {
660
+ const owned = kind === "normal" ? client : kind === "delete" ? deleteClient : presignClient;
661
+ if (!owned)
662
+ continue;
663
+ try {
664
+ owned.destroy();
665
+ if (kind === "normal")
666
+ client = null;
667
+ else if (kind === "delete")
668
+ deleteClient = null;
669
+ else presignClient = null;
670
+ } catch (error) {
671
+ failures.push(error);
672
+ }
673
+ }
674
+ if (failures.length > 0)
675
+ throw new AggregateError(failures, "S3 client cleanup failed");
676
+ }).finally(() => {
677
+ closing = null;
678
+ });
679
+ return closing;
680
+ }
681
+ function getClient(operation) {
682
+ const selected = operation === "deleteMany" ? deleteClient : operation === "presign" ? presignClient ?? client : client;
683
+ if (!ready || !selected) {
493
684
  throw new Error(storageM("storage_s3ClientNotInitialized"));
494
685
  }
495
- return client;
686
+ return selected;
496
687
  }
497
688
  function getConfig() {
498
- if (!config) {
689
+ if (!ready || !config) {
499
690
  throw new Error(storageM("storage_s3ConfigNotInitialized"));
500
691
  }
501
692
  return config;
@@ -503,29 +694,59 @@ function createS3Provider() {
503
694
  function fullKey(key) {
504
695
  return withPrefix(key, getConfig().prefix);
505
696
  }
697
+ function logOperation(category, operation, payload) {
698
+ logStorageOperation(logger2, config?.operationLog, category, operation, payload);
699
+ }
506
700
  const file = {
507
701
  async put(key, data, options = {}) {
508
702
  try {
509
703
  const s3Client = getClient();
510
704
  const s3Config = getConfig();
511
705
  const fullPath = fullKey(key);
706
+ options.signal?.throwIfAborted();
512
707
  const body = typeof data === "string" ? Buffer.from(data) : data;
513
- await s3Client.send(new PutObjectCommand({
708
+ const buffered = body instanceof Uint8Array;
709
+ let size = buffered ? body.byteLength : 0;
710
+ const params = {
514
711
  Bucket: s3Config.bucket,
515
712
  Key: fullPath,
516
- Body: body,
517
713
  ContentType: options.contentType,
518
714
  Metadata: options.metadata,
519
715
  CacheControl: options.cacheControl,
520
- ContentDisposition: options.contentDisposition
521
- }));
716
+ ContentDisposition: options.contentDisposition,
717
+ IfNoneMatch: options.ifNoneMatch
718
+ };
719
+ logOperation("write", "file.put", { key, fullPath, byteLength: buffered ? size : void 0, options });
720
+ if (buffered) {
721
+ await s3Client.send(new PutObjectCommand({ ...params, Body: body }), { abortSignal: options.signal });
722
+ } else {
723
+ async function* counted() {
724
+ for await (const chunk of toByteStream(data, options.signal)) {
725
+ size += chunk.byteLength;
726
+ yield chunk;
727
+ }
728
+ }
729
+ const stream = Readable.from(counted(), { objectMode: false });
730
+ const controller = new AbortController();
731
+ const abort = () => controller.abort();
732
+ options.signal?.addEventListener("abort", abort, { once: true });
733
+ try {
734
+ if (options.signal?.aborted)
735
+ abort();
736
+ const { Upload } = await import('@aws-sdk/lib-storage');
737
+ await new Upload({ client: s3Client, params: { ...params, Body: stream }, queueSize: 2, leavePartsOnError: false, abortController: controller }).done();
738
+ } finally {
739
+ options.signal?.removeEventListener("abort", abort);
740
+ stream.destroy();
741
+ }
742
+ }
522
743
  const headResult = await file.head(key);
523
744
  if (headResult.success) {
524
745
  return headResult;
525
746
  }
526
747
  return ok({
527
748
  key,
528
- size: body.length,
749
+ size,
529
750
  contentType: options.contentType || "application/octet-stream",
530
751
  lastModified: /* @__PURE__ */ new Date()
531
752
  });
@@ -534,10 +755,24 @@ function createS3Provider() {
534
755
  }
535
756
  },
536
757
  async get(key, options = {}) {
758
+ const streamed = await file.getStream(key, options);
759
+ if (!streamed.success)
760
+ return streamed;
761
+ try {
762
+ const chunks = [];
763
+ for await (const chunk of streamed.data)
764
+ chunks.push(chunk);
765
+ return ok(Buffer.concat(chunks));
766
+ } catch (error) {
767
+ return err(toStorageError2(error, key));
768
+ }
769
+ },
770
+ async getStream(key, options = {}) {
537
771
  try {
538
772
  const s3Client = getClient();
539
773
  const s3Config = getConfig();
540
774
  const fullPath = fullKey(key);
775
+ logOperation("read", "file.getStream", { key, fullPath, options });
541
776
  let Range;
542
777
  if (options.rangeStart !== void 0 || options.rangeEnd !== void 0) {
543
778
  const start = options.rangeStart ?? 0;
@@ -548,19 +783,15 @@ function createS3Provider() {
548
783
  Bucket: s3Config.bucket,
549
784
  Key: fullPath,
550
785
  Range
551
- }));
786
+ }), { abortSignal: options.signal });
552
787
  if (!response.Body) {
553
788
  return err(
554
789
  HaiStorageError.OPERATION_FAILED,
555
790
  storageM("storage_responseBodyEmpty")
556
791
  );
557
792
  }
558
- const chunks = [];
559
793
  const stream = response.Body;
560
- for await (const chunk of stream) {
561
- chunks.push(chunk);
562
- }
563
- return ok(Buffer.concat(chunks));
794
+ return ok(stream);
564
795
  } catch (error) {
565
796
  return err(toStorageError2(error, key));
566
797
  }
@@ -570,6 +801,7 @@ function createS3Provider() {
570
801
  const s3Client = getClient();
571
802
  const s3Config = getConfig();
572
803
  const fullPath = fullKey(key);
804
+ logOperation("read", "file.head", { key, fullPath });
573
805
  const response = await s3Client.send(new HeadObjectCommand({
574
806
  Bucket: s3Config.bucket,
575
807
  Key: fullPath
@@ -587,6 +819,7 @@ function createS3Provider() {
587
819
  }
588
820
  },
589
821
  async exists(key) {
822
+ logOperation("read", "file.exists", { key });
590
823
  const result = await file.head(key);
591
824
  if (result.success) {
592
825
  return ok(true);
@@ -601,6 +834,7 @@ function createS3Provider() {
601
834
  const s3Client = getClient();
602
835
  const s3Config = getConfig();
603
836
  const fullPath = fullKey(key);
837
+ logOperation("write", "file.delete", { key, fullPath });
604
838
  await s3Client.send(new DeleteObjectCommand({
605
839
  Bucket: s3Config.bucket,
606
840
  Key: fullPath
@@ -612,18 +846,65 @@ function createS3Provider() {
612
846
  },
613
847
  async deleteMany(keys) {
614
848
  try {
615
- const s3Client = getClient();
849
+ const s3Client = getClient("deleteMany");
616
850
  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) }))
851
+ logOperation("write", "file.deleteMany", { count: keys.length, keys });
852
+ const uniqueKeys = [...new Set(keys)];
853
+ const summary = { deletedKeys: [], failures: [] };
854
+ for (let offset = 0; offset < uniqueKeys.length; offset += DELETE_BATCH_SIZE) {
855
+ let pending = uniqueKeys.slice(offset, offset + DELETE_BATCH_SIZE);
856
+ for (let attempt = 0; pending.length > 0 && attempt < 3; attempt++) {
857
+ let response;
858
+ try {
859
+ response = await s3Client.send(new DeleteObjectsCommand({
860
+ Bucket: s3Config.bucket,
861
+ Delete: { Objects: [...new Set(pending.map(fullKey))].map((Key) => ({ Key })), Quiet: false }
862
+ }));
863
+ } catch (cause) {
864
+ const failure = cause && typeof cause === "object" ? cause : {};
865
+ const status = failure.$metadata?.httpStatusCode;
866
+ const code = failure.name && /^[\w.-]{1,80}$/.test(failure.name) ? failure.name : "RequestFailed";
867
+ const retryable = status === void 0 || status === 408 || status === 429 || status >= 500;
868
+ const outcome = status !== void 0 && status >= 400 && status < 500 && status !== 408 ? "failed" : "unknown";
869
+ summary.failures.push(...pending.map((key) => ({ key, code, retryable, outcome })));
870
+ summary.failures.push(...uniqueKeys.slice(offset + DELETE_BATCH_SIZE).map((key) => ({ key, code, retryable, outcome: "notAttempted" })));
871
+ return toStorageDeleteResult(summary);
872
+ }
873
+ const outcomes = /* @__PURE__ */ new Map();
874
+ const requested = new Set(pending.map(fullKey));
875
+ let malformed = false;
876
+ const items = [
877
+ ...(response.Deleted ?? []).map((item) => ({ key: item.Key, code: null })),
878
+ ...(response.Errors ?? []).map((item) => ({ key: item.Key, code: item.Code && /^[\w.-]{1,80}$/.test(item.Code) ? item.Code : "UnknownError" }))
879
+ ];
880
+ for (const item of items) {
881
+ if (!item.key || !requested.has(item.key) || outcomes.has(item.key)) {
882
+ malformed = true;
883
+ break;
884
+ }
885
+ outcomes.set(item.key, item.code);
886
+ }
887
+ if (malformed || outcomes.size !== requested.size) {
888
+ summary.failures.push(...pending.map((key) => ({ key, code: "InvalidDeleteResponse", retryable: false, outcome: "unknown" })));
889
+ summary.failures.push(...uniqueKeys.slice(offset + DELETE_BATCH_SIZE).map((key) => ({ key, code: "InvalidDeleteResponse", retryable: false, outcome: "notAttempted" })));
890
+ return toStorageDeleteResult(summary);
891
+ }
892
+ const retry = [];
893
+ for (const key of pending) {
894
+ const code = outcomes.get(fullKey(key));
895
+ if (code === null)
896
+ summary.deletedKeys.push(key);
897
+ else if (code && RETRYABLE_DELETE_CODES.has(code) && attempt < 2)
898
+ retry.push(key);
899
+ else
900
+ summary.failures.push({ key, code: code ?? "UnknownError", retryable: !!code && RETRYABLE_DELETE_CODES.has(code), outcome: "failed" });
901
+ }
902
+ pending = retry;
903
+ if (pending.length > 0)
904
+ await new Promise((resolve2) => setTimeout(resolve2, 100 * 2 ** attempt));
624
905
  }
625
- }));
626
- return ok(void 0);
906
+ }
907
+ return toStorageDeleteResult(summary);
627
908
  } catch (error) {
628
909
  return err(toStorageError2(error));
629
910
  }
@@ -634,6 +915,7 @@ function createS3Provider() {
634
915
  const s3Config = getConfig();
635
916
  const sourceFullPath = fullKey(sourceKey);
636
917
  const destFullPath = fullKey(destKey);
918
+ logOperation("write", "file.copy", { sourceKey, destKey, sourceFullPath, destFullPath, options });
637
919
  await s3Client.send(new CopyObjectCommand({
638
920
  Bucket: s3Config.bucket,
639
921
  CopySource: `${s3Config.bucket}/${sourceFullPath}`,
@@ -653,6 +935,7 @@ function createS3Provider() {
653
935
  try {
654
936
  const s3Client = getClient();
655
937
  const s3Config = getConfig();
938
+ logOperation("read", "dir.list", { options });
656
939
  let prefix = s3Config.prefix || "";
657
940
  if (options.prefix) {
658
941
  prefix = withPrefix(options.prefix, s3Config.prefix);
@@ -685,6 +968,7 @@ function createS3Provider() {
685
968
  },
686
969
  async delete(prefix) {
687
970
  try {
971
+ logOperation("write", "dir.delete", { prefix });
688
972
  let continuationToken;
689
973
  do {
690
974
  const listResult = await dir.list({
@@ -713,17 +997,19 @@ function createS3Provider() {
713
997
  const presign = {
714
998
  async getUrl(key, options) {
715
999
  try {
716
- const s3Client = getClient();
1000
+ const parsed = PresignOptionsSchema.parse(options ?? {});
1001
+ const s3Client = getClient("presign");
717
1002
  const s3Config = getConfig();
718
1003
  const fullPath = fullKey(key);
1004
+ logOperation("read", "presign.getUrl", { key, fullPath, options });
719
1005
  const command = new GetObjectCommand({
720
1006
  Bucket: s3Config.bucket,
721
1007
  Key: fullPath,
722
- ResponseContentType: options?.responseContentType,
723
- ResponseContentDisposition: options?.responseContentDisposition
1008
+ ResponseContentType: parsed.responseContentType,
1009
+ ResponseContentDisposition: parsed.responseContentDisposition
724
1010
  });
725
1011
  const url = await getSignedUrl(s3Client, command, {
726
- expiresIn: options?.expiresIn || 3600
1012
+ expiresIn: parsed.expiresIn
727
1013
  });
728
1014
  return ok(url);
729
1015
  } catch (error) {
@@ -736,16 +1022,18 @@ function createS3Provider() {
736
1022
  },
737
1023
  async putUrl(key, options) {
738
1024
  try {
739
- const s3Client = getClient();
1025
+ const parsed = PresignUploadOptionsSchema.parse(options ?? {});
1026
+ const s3Client = getClient("presign");
740
1027
  const s3Config = getConfig();
741
1028
  const fullPath = fullKey(key);
1029
+ logOperation("write", "presign.putUrl", { key, fullPath, options });
742
1030
  const command = new PutObjectCommand({
743
1031
  Bucket: s3Config.bucket,
744
1032
  Key: fullPath,
745
- ContentType: options?.contentType || "application/octet-stream"
1033
+ ContentType: parsed.contentType
746
1034
  });
747
1035
  const url = await getSignedUrl(s3Client, command, {
748
- expiresIn: options?.expiresIn || 3600
1036
+ expiresIn: parsed.expiresIn
749
1037
  });
750
1038
  return ok(url);
751
1039
  } catch (error) {
@@ -762,7 +1050,8 @@ function createS3Provider() {
762
1050
  return null;
763
1051
  }
764
1052
  const fullPath = fullKey(key);
765
- return `${s3Config.publicUrl.replace(/\/+$/, "")}/${fullPath}`;
1053
+ logOperation("read", "presign.publicUrl", { key, fullPath });
1054
+ return `${s3Config.publicUrl.replace(TRAILING_SLASHES_REGEX, "")}/${fullPath}`;
766
1055
  }
767
1056
  };
768
1057
  return {
@@ -778,53 +1067,146 @@ function createS3Provider() {
778
1067
  );
779
1068
  }
780
1069
  try {
1070
+ await closeClients();
781
1071
  config = cfg;
782
1072
  logger2.info("Connecting S3 provider", {
783
1073
  bucket: cfg.bucket,
784
1074
  region: cfg.region,
785
- endpoint: cfg.endpoint ? sanitizeEndpointUrl(cfg.endpoint) : void 0
1075
+ endpoint: cfg.endpoint ? core.sanitize.sanitizeUrl(cfg.endpoint) : void 0
786
1076
  });
787
- client = new S3Client({
1077
+ const clientConfig = {
788
1078
  region: cfg.region,
789
1079
  endpoint: cfg.endpoint,
790
1080
  forcePathStyle: cfg.forcePathStyle,
1081
+ // 不强制兼容服务实现 AWS checksum 扩展;校验工具按实际字节独立计算。
1082
+ requestChecksumCalculation: "WHEN_REQUIRED",
1083
+ responseChecksumValidation: "WHEN_REQUIRED",
791
1084
  credentials: {
792
1085
  accessKeyId: cfg.accessKeyId,
793
1086
  secretAccessKey: cfg.secretAccessKey
794
1087
  }
795
- });
1088
+ };
1089
+ client = new S3Client(clientConfig);
1090
+ deleteClient = new S3Client({ ...clientConfig, maxAttempts: 1 });
1091
+ if (cfg.publicEndpoint)
1092
+ presignClient = new S3Client({ ...clientConfig, endpoint: cfg.publicEndpoint });
1093
+ if (cfg.createBucket) {
1094
+ try {
1095
+ await client.send(new HeadBucketCommand({ Bucket: cfg.bucket }));
1096
+ } catch (error) {
1097
+ if (!(error && typeof error === "object" && "$metadata" in error && error.$metadata && typeof error.$metadata === "object" && "httpStatusCode" in error.$metadata && error.$metadata.httpStatusCode === 404)) {
1098
+ throw error;
1099
+ }
1100
+ try {
1101
+ await client.send(new CreateBucketCommand({
1102
+ Bucket: cfg.bucket,
1103
+ // AWS us-east-1 要省略 LocationConstraint;兼容服务可使用自定义区域名。
1104
+ ...cfg.region === "us-east-1" ? {} : { CreateBucketConfiguration: { LocationConstraint: cfg.region } }
1105
+ }));
1106
+ logger2.info("S3 bucket created", { bucket: cfg.bucket });
1107
+ } catch (createError) {
1108
+ if (!(createError instanceof Error && createError.name === "BucketAlreadyOwnedByYou"))
1109
+ throw createError;
1110
+ await client.send(new HeadBucketCommand({ Bucket: cfg.bucket }));
1111
+ }
1112
+ }
1113
+ }
796
1114
  await client.send(new ListObjectsV2Command({
797
1115
  Bucket: cfg.bucket,
798
1116
  MaxKeys: 1
799
1117
  }));
1118
+ config = cfg;
1119
+ ready = true;
800
1120
  logger2.info("S3 provider connected", { bucket: cfg.bucket });
801
1121
  return ok(void 0);
802
1122
  } catch (error) {
803
1123
  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");
1124
+ const original = toStorageError2(error);
1125
+ try {
1126
+ await closeClients();
1127
+ return err(original);
1128
+ } catch (cleanupError) {
1129
+ return err({ ...original, ext: { ...original.ext, cleanupPending: true, cleanupError } });
1130
+ }
815
1131
  }
816
1132
  },
1133
+ close: closeClients,
817
1134
  isConnected() {
818
- return client !== null && config !== null;
1135
+ return ready && client !== null && deleteClient !== null && config !== null;
819
1136
  }
820
1137
  };
821
1138
  }
1139
+ var HEX_CHECKSUM = /^[\da-f]+$/i;
1140
+ var BASE64_CHECKSUM = /^(?:[A-Z\d+/]{4})*(?:[A-Z\d+/]{2}==|[A-Z\d+/]{3}=)?$/i;
1141
+ var ALGORITHMS = /* @__PURE__ */ new Set(["sha256", "sha384", "sha512", "sha1", "md5"]);
1142
+ async function generate(data, options = {}) {
1143
+ try {
1144
+ const algorithm = options.algorithm ?? "sha256";
1145
+ if (!ALGORITHMS.has(algorithm))
1146
+ return err(HaiStorageError.INVALID_CHECKSUM, storageM("storage_checksumInvalid"));
1147
+ const hash = createHash(algorithm);
1148
+ for await (const chunk of toByteStream(data, options.signal))
1149
+ hash.update(chunk);
1150
+ return ok(hash.digest(options.encoding ?? "hex"));
1151
+ } catch (error) {
1152
+ return err(HaiStorageError.CHECKSUM_FAILED, storageM("storage_checksumFailed"), error);
1153
+ }
1154
+ }
1155
+ var checksum = {
1156
+ generate,
1157
+ async verify(data, expected, options = {}) {
1158
+ const encoding = options.encoding ?? "hex";
1159
+ const valid = encoding === "hex" ? expected.length % 2 === 0 && HEX_CHECKSUM.test(expected) : BASE64_CHECKSUM.test(expected) && expected.length > 0;
1160
+ if (!valid)
1161
+ return err(HaiStorageError.INVALID_CHECKSUM, storageM("storage_checksumInvalid"));
1162
+ const calculated = await generate(data, options);
1163
+ if (!calculated.success)
1164
+ return calculated;
1165
+ const actualBytes = Buffer.from(calculated.data, encoding);
1166
+ const expectedBytes = Buffer.from(expected, encoding);
1167
+ return ok(actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes));
1168
+ }
1169
+ };
822
1170
 
823
1171
  // src/storage-main.ts
824
1172
  var logger3 = core.logger.child({ module: "storage", scope: "main" });
825
1173
  var currentProvider = null;
826
1174
  var currentConfig = null;
827
1175
  var initInProgress = false;
1176
+ var pendingProvider = null;
1177
+ var initCompletion = null;
1178
+ var closingPromise = null;
1179
+ async function closeOwnedProvider() {
1180
+ if (currentProvider) {
1181
+ pendingProvider = currentProvider;
1182
+ currentProvider = null;
1183
+ }
1184
+ currentConfig = null;
1185
+ if (!pendingProvider)
1186
+ return ok(void 0);
1187
+ try {
1188
+ await pendingProvider.close();
1189
+ pendingProvider = null;
1190
+ return ok(void 0);
1191
+ } catch (error) {
1192
+ logger3.error("Provider cleanup failed; retaining resource for retry", { error });
1193
+ return err({ ...HaiStorageError.CONNECTION_FAILED, message: storageM("storage_operationFailed", {
1194
+ params: { error: error instanceof Error ? error.message : String(error) }
1195
+ }), cause: error, ext: { cleanupPending: true } });
1196
+ }
1197
+ }
1198
+ async function failInitialization(result) {
1199
+ const cleanup = await closeOwnedProvider();
1200
+ if (result.success)
1201
+ return result;
1202
+ if (cleanup.success) {
1203
+ const ext = { ...result.error.ext };
1204
+ delete ext.cleanupPending;
1205
+ delete ext.cleanupError;
1206
+ return err({ ...result.error, ext: Object.keys(ext).length > 0 ? ext : void 0 });
1207
+ }
1208
+ return err({ ...result.error, ext: { ...result.error.ext, cleanupPending: true, cleanupError: cleanup.error } });
1209
+ }
828
1210
  function createProvider(config) {
829
1211
  switch (config.type) {
830
1212
  case "s3":
@@ -849,17 +1231,27 @@ var notInitializedPresign = new Proxy(
849
1231
  }
850
1232
  );
851
1233
  var storage = {
1234
+ checksum,
852
1235
  /**
853
1236
  * 初始化存储连接。
854
1237
  *
855
1238
  * 如果当前已有活跃连接,会先自动 close 再重新初始化。
856
- * 配置会通过 Zod Schema 校验;校验失败或连接异常会返回 `CONNECTION_FAILED`。
1239
+ * 配置通过 Zod Schema 校验;校验、连接或清理失败均返回 HaiResult,不发布失败候选。
857
1240
  *
858
1241
  * @param config 存储配置(S3 或本地),支持省略带默认值的字段。
859
1242
  * @returns 成功时返回 ok(undefined);失败时返回包含错误码和消息的 err。
1243
+ * @example
1244
+ * ```ts
1245
+ * import { storage } from '@h-ai/storage'
1246
+ *
1247
+ * const initialized = await storage.init({ type: 'local', root: './data/uploads' })
1248
+ * if (!initialized.success) throw new Error(initialized.error.message)
1249
+ * const closed = await storage.close()
1250
+ * if (!closed.success) throw new Error(closed.error.message)
1251
+ * ```
860
1252
  */
861
1253
  async init(config) {
862
- if (initInProgress) {
1254
+ if (initInProgress || closingPromise) {
863
1255
  logger3.warn("Storage init already in progress, skipping concurrent call");
864
1256
  return err(
865
1257
  HaiStorageError.OPERATION_FAILED,
@@ -867,11 +1259,14 @@ var storage = {
867
1259
  );
868
1260
  }
869
1261
  initInProgress = true;
1262
+ let resolveInit;
1263
+ initCompletion = new Promise((resolve2) => {
1264
+ resolveInit = resolve2;
1265
+ });
870
1266
  try {
871
- if (currentProvider) {
872
- logger3.warn("Storage module is already initialized, reinitializing");
873
- await storage.close();
874
- }
1267
+ const cleanup = await closeOwnedProvider();
1268
+ if (!cleanup.success)
1269
+ return cleanup;
875
1270
  logger3.info("Initializing storage module");
876
1271
  const parseResult = StorageConfigSchema.safeParse(config);
877
1272
  if (!parseResult.success) {
@@ -883,30 +1278,36 @@ var storage = {
883
1278
  );
884
1279
  }
885
1280
  const parsed = parseResult.data;
1281
+ if (core.module.requiresSharedState(process.env) && parsed.type === "local")
1282
+ return err(HaiStorageError.CONFIG_ERROR, storageM("storage_sharedBackendRequired"));
886
1283
  const provider = createProvider(parsed);
1284
+ pendingProvider = provider;
887
1285
  const connectResult = await provider.connect(parsed);
888
1286
  if (!connectResult.success) {
889
1287
  logger3.error("Storage module initialization failed", {
890
1288
  code: connectResult.error.code,
891
1289
  message: connectResult.error.message
892
1290
  });
893
- return connectResult;
1291
+ return await failInitialization(connectResult);
894
1292
  }
1293
+ pendingProvider = null;
895
1294
  currentProvider = provider;
896
1295
  currentConfig = parsed;
897
1296
  logger3.info("Storage module initialized");
898
1297
  return ok(void 0);
899
1298
  } catch (error) {
900
1299
  logger3.error("Storage module initialization failed", { error });
901
- return err(
1300
+ return await failInitialization(err(
902
1301
  HaiStorageError.CONNECTION_FAILED,
903
1302
  storageM("storage_operationFailed", {
904
1303
  params: { error: error instanceof Error ? error.message : String(error) }
905
1304
  }),
906
1305
  error
907
- );
1306
+ ));
908
1307
  } finally {
909
1308
  initInProgress = false;
1309
+ initCompletion = null;
1310
+ resolveInit();
910
1311
  }
911
1312
  },
912
1313
  /** 文件操作接口。未初始化时所有方法返回 NOT_INITIALIZED 错误 */
@@ -921,36 +1322,39 @@ var storage = {
921
1322
  get presign() {
922
1323
  return currentProvider?.presign ?? notInitializedPresign;
923
1324
  },
924
- /** 当前解析后的存储配置;未初始化或已关闭时为 null */
1325
+ /** 当前脱敏后的存储配置快照;未初始化或已关闭时为 null */
925
1326
  get config() {
926
- return currentConfig;
1327
+ return currentConfig ? core.sanitize.sanitizeSensitiveFields(currentConfig) : null;
927
1328
  },
928
1329
  /** 是否已完成初始化 */
929
1330
  get isInitialized() {
930
- return currentProvider !== null;
1331
+ return currentProvider !== null && currentProvider.isConnected();
931
1332
  },
932
1333
  /**
933
1334
  * 关闭存储连接并释放资源
934
1335
  *
935
1336
  * 关闭后 file/dir/presign 操作将返回 NOT_INITIALIZED 错误。
936
- * 重复调用不会报错。
1337
+ * 等待初始化并合并并发关闭;失败时保留资源供 close/init 重试。
1338
+ * @returns 所有客户端释放成功才返回成功。
1339
+ * @example
1340
+ * ```ts
1341
+ * import { storage } from '@h-ai/storage'
1342
+ *
1343
+ * const closed = await storage.close()
1344
+ * if (!closed.success) throw new Error(closed.error.message)
1345
+ * ```
937
1346
  */
938
1347
  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;
1348
+ if (!closingPromise) {
1349
+ closingPromise = (async () => {
1350
+ if (initCompletion)
1351
+ await initCompletion;
1352
+ return await closeOwnedProvider();
1353
+ })().finally(() => {
1354
+ closingPromise = null;
1355
+ });
953
1356
  }
1357
+ return await closingPromise;
954
1358
  }
955
1359
  };
956
1360