@aigne/afs-s3 1.11.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,1410 @@
1
+ import { AFSBaseProvider, AFSError, AFSNotFoundError, Actions, Delete, Explain, List, Meta, Read, Search, Stat, Write } from "@aigne/afs";
2
+ import { camelize, optionalize, zodParse } from "@aigne/afs/utils/zod";
3
+ import { AbortMultipartUploadCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectVersionsCommand, ListObjectsV2Command, PutObjectCommand, S3Client, SelectObjectContentCommand, UploadPartCommand } from "@aws-sdk/client-s3";
4
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
5
+ import { joinURL } from "ufo";
6
+ import { z } from "zod";
7
+
8
+ //#region src/cache.ts
9
+ /**
10
+ * LRU Cache with TTL support
11
+ */
12
+ var LRUCache = class {
13
+ cache = /* @__PURE__ */ new Map();
14
+ maxSize;
15
+ defaultTtl;
16
+ /**
17
+ * Create a new LRU cache
18
+ *
19
+ * @param maxSize - Maximum number of entries (default: 1000)
20
+ * @param defaultTtl - Default TTL in seconds (default: 60)
21
+ */
22
+ constructor(maxSize = 1e3, defaultTtl = 60) {
23
+ this.maxSize = maxSize;
24
+ this.defaultTtl = defaultTtl;
25
+ }
26
+ /**
27
+ * Get a value from the cache
28
+ *
29
+ * @param key - Cache key
30
+ * @returns Cached value or undefined if not found/expired
31
+ */
32
+ get(key) {
33
+ const entry = this.cache.get(key);
34
+ if (!entry) return;
35
+ if (Date.now() > entry.expiresAt) {
36
+ this.cache.delete(key);
37
+ return;
38
+ }
39
+ this.cache.delete(key);
40
+ this.cache.set(key, entry);
41
+ return entry.value;
42
+ }
43
+ /**
44
+ * Set a value in the cache
45
+ *
46
+ * @param key - Cache key
47
+ * @param value - Value to cache
48
+ * @param ttl - TTL in seconds (optional, uses default)
49
+ */
50
+ set(key, value, ttl) {
51
+ if (this.cache.has(key)) this.cache.delete(key);
52
+ while (this.cache.size >= this.maxSize) {
53
+ const oldestKey = this.cache.keys().next().value;
54
+ if (oldestKey) this.cache.delete(oldestKey);
55
+ }
56
+ const expiresAt = Date.now() + (ttl ?? this.defaultTtl) * 1e3;
57
+ this.cache.set(key, {
58
+ value,
59
+ expiresAt
60
+ });
61
+ }
62
+ /**
63
+ * Delete a value from the cache
64
+ *
65
+ * @param key - Cache key
66
+ */
67
+ delete(key) {
68
+ this.cache.delete(key);
69
+ }
70
+ /**
71
+ * Delete all entries matching a prefix
72
+ *
73
+ * @param prefix - Key prefix to match
74
+ */
75
+ deleteByPrefix(prefix) {
76
+ for (const key of this.cache.keys()) if (key.startsWith(prefix)) this.cache.delete(key);
77
+ }
78
+ /**
79
+ * Clear all entries
80
+ */
81
+ clear() {
82
+ this.cache.clear();
83
+ }
84
+ /**
85
+ * Get the number of entries in the cache
86
+ */
87
+ get size() {
88
+ return this.cache.size;
89
+ }
90
+ /**
91
+ * Prune expired entries
92
+ */
93
+ prune() {
94
+ const now = Date.now();
95
+ for (const [key, entry] of this.cache.entries()) if (now > entry.expiresAt) this.cache.delete(key);
96
+ }
97
+ };
98
+ /**
99
+ * Create a cache key from bucket, prefix, and path
100
+ */
101
+ function createCacheKey(bucket, prefix, path, suffix) {
102
+ const base = `${bucket}:${prefix}:${path}`;
103
+ return suffix ? `${base}:${suffix}` : base;
104
+ }
105
+
106
+ //#endregion
107
+ //#region src/client.ts
108
+ /**
109
+ * S3 Client Factory
110
+ *
111
+ * Creates and configures AWS S3 clients.
112
+ */
113
+ /**
114
+ * Create an S3 client with the given options
115
+ *
116
+ * Uses AWS SDK's default credential chain:
117
+ * 1. Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
118
+ * 2. Shared credentials file (~/.aws/credentials)
119
+ * 3. IAM role (EC2/ECS/Lambda)
120
+ * 4. SSO credentials
121
+ *
122
+ * @param options - S3 provider options
123
+ * @returns Configured S3 client
124
+ */
125
+ function createS3Client(options) {
126
+ const config = {};
127
+ if (options.region) config.region = options.region;
128
+ else if (options.endpoint) config.region = "us-east-1";
129
+ if (options.endpoint) config.endpoint = options.endpoint;
130
+ if (options.forcePathStyle) config.forcePathStyle = true;
131
+ if (options.credentials) config.credentials = {
132
+ accessKeyId: options.credentials.accessKeyId,
133
+ secretAccessKey: options.credentials.secretAccessKey,
134
+ sessionToken: options.credentials.sessionToken
135
+ };
136
+ return new S3Client(config);
137
+ }
138
+
139
+ //#endregion
140
+ //#region src/errors.ts
141
+ /**
142
+ * S3 Error Handling
143
+ *
144
+ * Maps AWS S3 errors to AFS errors.
145
+ */
146
+ /**
147
+ * AFS error codes
148
+ */
149
+ const AFSErrorCode = {
150
+ ENTRY_NOT_FOUND: "ENTRY_NOT_FOUND",
151
+ MODULE_NOT_FOUND: "MODULE_NOT_FOUND",
152
+ PERMISSION_DENIED: "PERMISSION_DENIED",
153
+ AUTH_ERROR: "AUTH_ERROR",
154
+ RATE_LIMITED: "RATE_LIMITED",
155
+ TYPE_MISMATCH: "TYPE_MISMATCH",
156
+ INTERNAL_ERROR: "INTERNAL_ERROR"
157
+ };
158
+ /**
159
+ * AFS Error class
160
+ */
161
+ var AFSError$1 = class extends Error {
162
+ code;
163
+ retryAfter;
164
+ constructor(code, message, options) {
165
+ super(message);
166
+ this.name = "AFSError";
167
+ this.code = code;
168
+ this.retryAfter = options?.retryAfter;
169
+ }
170
+ };
171
+ /**
172
+ * Map S3 error to AFS error
173
+ *
174
+ * @param error - AWS S3 error
175
+ * @param path - Optional path for AFSNotFoundError (with leading slash)
176
+ * @returns AFS error (or throws the original error if it's already an AFS error)
177
+ */
178
+ function mapS3Error(error, path) {
179
+ if (error && typeof error === "object" && "name" in error) {
180
+ const errorObj = error;
181
+ if (errorObj.name === "AFSNotFoundError" || errorObj.name === "AFSError" || errorObj.code === "AFS_NOT_FOUND") throw error;
182
+ }
183
+ if (error && typeof error === "object" && "name" in error) {
184
+ const awsError = error;
185
+ const message = awsError.message ?? "Unknown S3 error";
186
+ switch (awsError.name) {
187
+ case "NoSuchBucket": return new AFSError$1(AFSErrorCode.MODULE_NOT_FOUND, `Bucket not found: ${message}`);
188
+ case "NoSuchKey":
189
+ case "NotFound":
190
+ if (path) return new AFSNotFoundError(path);
191
+ return new AFSError$1(AFSErrorCode.ENTRY_NOT_FOUND, `Object not found: ${message}`);
192
+ case "AccessDenied":
193
+ case "Forbidden": return new AFSError$1(AFSErrorCode.PERMISSION_DENIED, `Access denied: ${message}`);
194
+ case "InvalidAccessKeyId":
195
+ case "SignatureDoesNotMatch":
196
+ case "ExpiredToken":
197
+ case "TokenRefreshRequired": return new AFSError$1(AFSErrorCode.AUTH_ERROR, `Authentication failed: ${message}`);
198
+ case "SlowDown":
199
+ case "ServiceUnavailable": return new AFSError$1(AFSErrorCode.RATE_LIMITED, `Rate limited: ${message}`, { retryAfter: 1e3 });
200
+ default: {
201
+ const statusCode = awsError.$metadata?.httpStatusCode;
202
+ if (statusCode === 404) {
203
+ if (path) return new AFSNotFoundError(path);
204
+ return new AFSError$1(AFSErrorCode.ENTRY_NOT_FOUND, message);
205
+ }
206
+ if (statusCode === 403) return new AFSError$1(AFSErrorCode.PERMISSION_DENIED, message);
207
+ if (statusCode === 401) return new AFSError$1(AFSErrorCode.AUTH_ERROR, message);
208
+ if (statusCode === 429 || statusCode === 503) return new AFSError$1(AFSErrorCode.RATE_LIMITED, message, { retryAfter: 1e3 });
209
+ return new AFSError$1(AFSErrorCode.INTERNAL_ERROR, message);
210
+ }
211
+ }
212
+ }
213
+ if (error instanceof Error) return new AFSError$1(AFSErrorCode.INTERNAL_ERROR, error.message);
214
+ return new AFSError$1(AFSErrorCode.INTERNAL_ERROR, String(error));
215
+ }
216
+
217
+ //#endregion
218
+ //#region src/operations/multipart.ts
219
+ /**
220
+ * S3 Multipart Upload (Phase 2)
221
+ *
222
+ * Handles multipart upload for large files (>5GB).
223
+ * S3 requires multipart upload for objects larger than 5GB.
224
+ */
225
+ /**
226
+ * Minimum part size (5MB) - AWS S3 requirement
227
+ */
228
+ const MIN_PART_SIZE = 5 * 1024 * 1024;
229
+ /**
230
+ * Maximum part size (5GB) - AWS S3 requirement
231
+ */
232
+ const MAX_PART_SIZE = 5 * 1024 * 1024 * 1024;
233
+ /**
234
+ * Threshold for switching to multipart upload (100MB)
235
+ * Files larger than this will use multipart upload
236
+ */
237
+ const MULTIPART_THRESHOLD = 100 * 1024 * 1024;
238
+ /**
239
+ * Default part size (10MB)
240
+ */
241
+ const DEFAULT_PART_SIZE = 10 * 1024 * 1024;
242
+ /**
243
+ * Maximum number of parts (10,000) - AWS S3 requirement
244
+ */
245
+ const MAX_PARTS = 1e4;
246
+ /**
247
+ * Calculate optimal part size based on file size
248
+ */
249
+ function calculatePartSize(fileSize, requestedPartSize) {
250
+ let partSize = requestedPartSize ?? DEFAULT_PART_SIZE;
251
+ if (partSize < MIN_PART_SIZE) partSize = MIN_PART_SIZE;
252
+ if (partSize > MAX_PART_SIZE) partSize = MAX_PART_SIZE;
253
+ if (Math.ceil(fileSize / partSize) > MAX_PARTS) {
254
+ partSize = Math.ceil(fileSize / MAX_PARTS);
255
+ partSize = Math.ceil(partSize / (1024 * 1024)) * 1024 * 1024;
256
+ }
257
+ return partSize;
258
+ }
259
+ /**
260
+ * Upload a large file using multipart upload
261
+ *
262
+ * @param client - S3 client
263
+ * @param bucket - Bucket name
264
+ * @param key - Object key
265
+ * @param data - File content as Buffer
266
+ * @param options - Upload options
267
+ * @returns Upload result with ETag
268
+ */
269
+ async function multipartUpload(client, bucket, key, data, options) {
270
+ const fileSize = data.length;
271
+ const partSize = calculatePartSize(fileSize, options?.partSize);
272
+ const createCommand = new CreateMultipartUploadCommand({
273
+ Bucket: bucket,
274
+ Key: key,
275
+ ContentType: options?.contentType ?? "application/octet-stream",
276
+ Metadata: options?.metadata
277
+ });
278
+ const uploadId = (await client.send(createCommand)).UploadId;
279
+ if (!uploadId) throw new Error("Failed to initiate multipart upload: no uploadId returned");
280
+ const parts = [];
281
+ try {
282
+ const totalParts = Math.ceil(fileSize / partSize);
283
+ for (let partNumber = 1; partNumber <= totalParts; partNumber++) {
284
+ const start = (partNumber - 1) * partSize;
285
+ const end = Math.min(start + partSize, fileSize);
286
+ const partData = data.subarray(start, end);
287
+ const uploadPartCommand = new UploadPartCommand({
288
+ Bucket: bucket,
289
+ Key: key,
290
+ UploadId: uploadId,
291
+ PartNumber: partNumber,
292
+ Body: partData
293
+ });
294
+ const uploadPartResponse = await client.send(uploadPartCommand);
295
+ if (!uploadPartResponse.ETag) throw new Error(`Failed to upload part ${partNumber}: no ETag returned`);
296
+ parts.push({
297
+ ETag: uploadPartResponse.ETag,
298
+ PartNumber: partNumber
299
+ });
300
+ }
301
+ const completeCommand = new CompleteMultipartUploadCommand({
302
+ Bucket: bucket,
303
+ Key: key,
304
+ UploadId: uploadId,
305
+ MultipartUpload: { Parts: parts }
306
+ });
307
+ const completeResponse = await client.send(completeCommand);
308
+ return {
309
+ etag: completeResponse.ETag?.replace(/"/g, "") ?? "",
310
+ versionId: completeResponse.VersionId
311
+ };
312
+ } catch (error) {
313
+ try {
314
+ const abortCommand = new AbortMultipartUploadCommand({
315
+ Bucket: bucket,
316
+ Key: key,
317
+ UploadId: uploadId
318
+ });
319
+ await client.send(abortCommand);
320
+ } catch {}
321
+ throw mapS3Error(error);
322
+ }
323
+ }
324
+ /**
325
+ * Check if a file should use multipart upload
326
+ */
327
+ function shouldUseMultipart(size) {
328
+ return size >= MULTIPART_THRESHOLD;
329
+ }
330
+
331
+ //#endregion
332
+ //#region src/operations/select.ts
333
+ /**
334
+ * S3 Select Support (Phase 3)
335
+ *
336
+ * Runs SQL-like queries on CSV and JSON files stored in S3.
337
+ * This enables server-side filtering, reducing data transfer.
338
+ */
339
+ /**
340
+ * Detect input format from file extension
341
+ */
342
+ function detectInputFormat(path) {
343
+ switch (path.toLowerCase().split(".").pop()) {
344
+ case "csv":
345
+ case "tsv": return "CSV";
346
+ case "json":
347
+ case "jsonl":
348
+ case "ndjson": return "JSON";
349
+ case "parquet": return "Parquet";
350
+ default: return "JSON";
351
+ }
352
+ }
353
+ /**
354
+ * Run a SQL-like query on an S3 object
355
+ *
356
+ * @param client - S3 client
357
+ * @param bucket - Bucket name
358
+ * @param mountPrefix - Mount prefix from options
359
+ * @param path - Path relative to mount point
360
+ * @param query - SQL query (e.g., "SELECT * FROM s3object WHERE age > 21")
361
+ * @param options - Select options
362
+ * @returns Query results
363
+ */
364
+ async function selectQuery(client, bucket, mountPrefix, path, query, options) {
365
+ try {
366
+ const normalizedPath = path.replace(/^\/+/, "").replace(/\/+$/, "");
367
+ const key = mountPrefix ? `${mountPrefix}/${normalizedPath}` : normalizedPath;
368
+ const inputFormat = options?.inputFormat ?? detectInputFormat(path);
369
+ const outputFormat = options?.outputFormat ?? "JSON";
370
+ const inputSerialization = {};
371
+ if (inputFormat === "CSV") inputSerialization.CSV = {
372
+ FieldDelimiter: options?.csv?.fieldDelimiter ?? ",",
373
+ RecordDelimiter: options?.csv?.recordDelimiter ?? "\n",
374
+ FileHeaderInfo: options?.csv?.fileHeaderInfo ?? "USE",
375
+ QuoteCharacter: options?.csv?.quoteCharacter ?? "\"",
376
+ Comments: options?.csv?.comments
377
+ };
378
+ else if (inputFormat === "JSON") inputSerialization.JSON = { Type: options?.json?.type ?? "DOCUMENT" };
379
+ else if (inputFormat === "Parquet") inputSerialization.Parquet = {};
380
+ const outputSerialization = {};
381
+ if (outputFormat === "JSON") outputSerialization.JSON = {};
382
+ else outputSerialization.CSV = {};
383
+ const command = new SelectObjectContentCommand({
384
+ Bucket: bucket,
385
+ Key: key,
386
+ ExpressionType: "SQL",
387
+ Expression: query,
388
+ InputSerialization: inputSerialization,
389
+ OutputSerialization: outputSerialization
390
+ });
391
+ const response = await client.send(command);
392
+ const records = [];
393
+ let stats;
394
+ if (response.Payload) for await (const event of response.Payload) {
395
+ if (event.Records?.Payload) {
396
+ const lines = new TextDecoder().decode(event.Records.Payload).split("\n").filter((line) => line.trim());
397
+ for (const line of lines) try {
398
+ records.push(JSON.parse(line));
399
+ } catch {
400
+ records.push(line);
401
+ }
402
+ }
403
+ if (event.Stats?.Details) stats = {
404
+ bytesScanned: Number(event.Stats.Details.BytesScanned ?? 0),
405
+ bytesProcessed: Number(event.Stats.Details.BytesProcessed ?? 0),
406
+ bytesReturned: Number(event.Stats.Details.BytesReturned ?? 0)
407
+ };
408
+ }
409
+ return {
410
+ records,
411
+ stats
412
+ };
413
+ } catch (error) {
414
+ throw mapS3Error(error);
415
+ }
416
+ }
417
+
418
+ //#endregion
419
+ //#region src/platform-ref.ts
420
+ /**
421
+ * Generate platform reference with AWS Console URL
422
+ *
423
+ * @param bucket - S3 bucket name
424
+ * @param region - AWS region (defaults to us-east-1)
425
+ * @param key - S3 object key (without leading slash)
426
+ * @param isDirectory - Whether this is a directory (prefix)
427
+ * @returns Platform reference with console URL
428
+ */
429
+ function generatePlatformRef(bucket, region, key, isDirectory) {
430
+ const effectiveRegion = region || "us-east-1";
431
+ if (isDirectory) return { consoleUrl: `https://s3.console.aws.amazon.com/s3/buckets/${bucket}?region=${effectiveRegion}&prefix=${key.endsWith("/") ? key : key ? `${key}/` : ""}` };
432
+ return { consoleUrl: `https://s3.console.aws.amazon.com/s3/object/${bucket}?region=${effectiveRegion}&prefix=${encodeURIComponent(key).replace(/%2F/g, "/")}` };
433
+ }
434
+
435
+ //#endregion
436
+ //#region src/types.ts
437
+ /**
438
+ * AFS S3 Provider Types
439
+ */
440
+ /**
441
+ * S3 bucket name validation regex
442
+ * - 3-63 characters
443
+ * - lowercase letters, numbers, hyphens, dots
444
+ * - must start and end with letter or number
445
+ */
446
+ const BUCKET_NAME_REGEX = /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/;
447
+ /**
448
+ * Zod schema for options validation
449
+ */
450
+ const afss3OptionsSchema = camelize(z.object({
451
+ name: optionalize(z.string()),
452
+ description: optionalize(z.string()),
453
+ bucket: z.string().regex(BUCKET_NAME_REGEX, "Invalid S3 bucket name"),
454
+ prefix: optionalize(z.string()),
455
+ region: optionalize(z.string()),
456
+ accessMode: optionalize(z.enum(["readonly", "readwrite"])),
457
+ endpoint: optionalize(z.string().url()),
458
+ forcePathStyle: optionalize(z.boolean()),
459
+ profile: optionalize(z.string()),
460
+ credentials: optionalize(z.object({
461
+ accessKeyId: z.string().meta({
462
+ sensitive: true,
463
+ env: ["AWS_ACCESS_KEY_ID"],
464
+ description: "AWS access key ID"
465
+ }),
466
+ secretAccessKey: z.string().meta({
467
+ sensitive: true,
468
+ env: ["AWS_SECRET_ACCESS_KEY"],
469
+ description: "AWS secret access key"
470
+ }),
471
+ sessionToken: optionalize(z.string().meta({
472
+ sensitive: true,
473
+ env: ["AWS_SESSION_TOKEN"],
474
+ description: "AWS session token"
475
+ }))
476
+ })),
477
+ cacheTtl: optionalize(z.number().int().min(0))
478
+ }).strict());
479
+
480
+ //#endregion
481
+ //#region \0@oxc-project+runtime@0.108.0/helpers/decorate.js
482
+ function __decorate(decorators, target, key, desc) {
483
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
484
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
485
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
486
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
487
+ }
488
+
489
+ //#endregion
490
+ //#region src/s3-afs.ts
491
+ /**
492
+ * AFS S3 Provider
493
+ *
494
+ * S3 provider using AFSBaseProvider decorator routing pattern.
495
+ * Provides access to AWS S3 and S3-compatible storage (MinIO, R2, B2).
496
+ */
497
+ /**
498
+ * Default URL expiration time (1 hour)
499
+ */
500
+ const DEFAULT_EXPIRES_IN = 3600;
501
+ /**
502
+ * Maximum expiration time (7 days)
503
+ */
504
+ const MAX_EXPIRES_IN = 604800;
505
+ /**
506
+ * AFSS3 Provider using Base Provider pattern
507
+ *
508
+ * Provides access to AWS S3 and S3-compatible storage through AFS.
509
+ * Uses decorator routing (@List, @Read, @Write, @Delete, @Meta, @Actions).
510
+ *
511
+ * @example
512
+ * ```typescript
513
+ * const s3 = new AFSS3({
514
+ * bucket: "my-bucket",
515
+ * prefix: "data",
516
+ * region: "us-east-1",
517
+ * });
518
+ *
519
+ * // Mount to AFS
520
+ * afs.mount(s3);
521
+ *
522
+ * // List objects
523
+ * const result = await afs.list("/modules/my-bucket/data");
524
+ *
525
+ * // Read object
526
+ * const content = await afs.read("/modules/my-bucket/data/file.json");
527
+ * ```
528
+ */
529
+ var AFSS3 = class AFSS3 extends AFSBaseProvider {
530
+ name;
531
+ description;
532
+ accessMode;
533
+ options;
534
+ client;
535
+ listCache;
536
+ statCache;
537
+ constructor(options) {
538
+ super();
539
+ const { client, uri: _uri, token: _token, auth: _auth, ...restOptions } = options;
540
+ const parsed = afss3OptionsSchema.parse(restOptions);
541
+ this.options = {
542
+ ...parsed,
543
+ bucket: parsed.bucket,
544
+ prefix: parsed.prefix ?? "",
545
+ accessMode: parsed.accessMode ?? "readonly"
546
+ };
547
+ this.name = parsed.name ?? parsed.bucket;
548
+ this.description = parsed.description ?? `S3 bucket: ${parsed.bucket}`;
549
+ this.accessMode = this.options.accessMode ?? "readonly";
550
+ this.client = client ?? createS3Client(this.options);
551
+ if (parsed.cacheTtl && parsed.cacheTtl > 0) {
552
+ this.listCache = new LRUCache(1e3, parsed.cacheTtl);
553
+ this.statCache = new LRUCache(5e3, parsed.cacheTtl);
554
+ }
555
+ }
556
+ /**
557
+ * Schema for configuration validation
558
+ */
559
+ static schema() {
560
+ return afss3OptionsSchema;
561
+ }
562
+ /**
563
+ * Provider manifest for URI-based discovery
564
+ */
565
+ static manifest() {
566
+ return {
567
+ name: "s3",
568
+ description: "AWS S3 and S3-compatible object storage (MinIO, R2, B2).\n- Browse, read, write, and delete objects; search by key prefix\n- Exec actions: `presign-download`, `presign-upload`, `multipart-upload`, `select` (SQL on objects)\n- Path structure: `/{key-prefix}/{object-key}`",
569
+ uriTemplate: "s3://{bucket}/{prefix+?}",
570
+ category: "cloud-storage",
571
+ schema: z.object({
572
+ bucket: z.string(),
573
+ prefix: z.string().optional(),
574
+ region: z.string().optional(),
575
+ accessKeyId: z.string().meta({ sensitive: true }).optional(),
576
+ secretAccessKey: z.string().meta({ sensitive: true }).optional(),
577
+ endpoint: z.string().optional(),
578
+ profile: z.string().optional()
579
+ }),
580
+ tags: [
581
+ "aws",
582
+ "s3",
583
+ "cloud",
584
+ "storage"
585
+ ]
586
+ };
587
+ }
588
+ /**
589
+ * Load from configuration file
590
+ */
591
+ static async load({ basePath, config } = {}) {
592
+ return new AFSS3(zodParse(afss3OptionsSchema, config, { prefix: basePath }));
593
+ }
594
+ /**
595
+ * Build the full S3 key from a path
596
+ */
597
+ buildS3Key(path) {
598
+ const normalizedPath = path.replace(/^\/+/, "").replace(/\/+$/, "");
599
+ return this.options.prefix ? `${this.options.prefix}/${normalizedPath}` : normalizedPath;
600
+ }
601
+ /**
602
+ * Generate a unique ID for an S3 object
603
+ */
604
+ generateId(key) {
605
+ const cleanKey = key.replace(/^\/+/, "");
606
+ return `s3://${this.options.bucket}/${cleanKey}`;
607
+ }
608
+ /**
609
+ * Invalidate caches for a given path
610
+ */
611
+ invalidateCache(path) {
612
+ if (this.statCache) {
613
+ const statKey = createCacheKey(this.options.bucket, this.options.prefix ?? "", path);
614
+ this.statCache.delete(statKey);
615
+ }
616
+ if (this.listCache) {
617
+ const parentPath = path.split("/").slice(0, -1).join("/") || "/";
618
+ const listPrefix = createCacheKey(this.options.bucket, this.options.prefix ?? "", parentPath);
619
+ this.listCache.deleteByPrefix(listPrefix);
620
+ }
621
+ }
622
+ /**
623
+ * Clear all caches
624
+ */
625
+ clearCache() {
626
+ this.listCache?.clear();
627
+ this.statCache?.clear();
628
+ }
629
+ async listHandler(ctx) {
630
+ try {
631
+ const normalizedPath = (ctx.params.path ?? "").replace(/^\/+/, "").replace(/\/+$/, "");
632
+ const fullPrefix = this.options.prefix ? normalizedPath ? `${this.options.prefix}/${normalizedPath}/` : `${this.options.prefix}/` : normalizedPath ? `${normalizedPath}/` : "";
633
+ const opts = ctx.options;
634
+ const maxChildren = opts?.limit ?? opts?.maxChildren ?? 1e3;
635
+ if (this.listCache) {
636
+ const cacheKey = createCacheKey(this.options.bucket, this.options.prefix ?? "", normalizedPath, JSON.stringify(ctx.options ?? {}));
637
+ const cached = this.listCache.get(cacheKey);
638
+ if (cached) return cached;
639
+ }
640
+ const result = await this.listWithDelimiter(fullPrefix, normalizedPath, maxChildren);
641
+ if (this.listCache) {
642
+ const cacheKey = createCacheKey(this.options.bucket, this.options.prefix ?? "", normalizedPath, JSON.stringify(ctx.options ?? {}));
643
+ this.listCache.set(cacheKey, result);
644
+ if (this.statCache) for (const entry of result.data) {
645
+ const statKey = createCacheKey(this.options.bucket, this.options.prefix ?? "", entry.path);
646
+ this.statCache.set(statKey, entry);
647
+ }
648
+ }
649
+ return result;
650
+ } catch (error) {
651
+ throw mapS3Error(error, ctx.params.path ? ctx.params.path.startsWith("/") ? ctx.params.path : `/${ctx.params.path}` : "/");
652
+ }
653
+ }
654
+ /**
655
+ * List with delimiter (single level)
656
+ */
657
+ async listWithDelimiter(prefix, basePath, maxChildren) {
658
+ const childEntries = [];
659
+ let continuationToken;
660
+ do {
661
+ const command = new ListObjectsV2Command({
662
+ Bucket: this.options.bucket,
663
+ Prefix: prefix,
664
+ Delimiter: "/",
665
+ MaxKeys: Math.min(maxChildren - childEntries.length, 1e3),
666
+ ContinuationToken: continuationToken
667
+ });
668
+ const response = await this.client.send(command);
669
+ if (response.CommonPrefixes) for (const commonPrefix of response.CommonPrefixes) {
670
+ if (!commonPrefix.Prefix) continue;
671
+ const dirName = commonPrefix.Prefix.slice(prefix.length).replace(/\/$/, "");
672
+ if (!dirName) continue;
673
+ const normalizedPath = joinURL("/", basePath, dirName);
674
+ childEntries.push({
675
+ id: this.generateId(commonPrefix.Prefix),
676
+ path: normalizedPath,
677
+ meta: {
678
+ childrenCount: -1,
679
+ platformRef: generatePlatformRef(this.options.bucket, this.options.region, commonPrefix.Prefix, true)
680
+ }
681
+ });
682
+ if (childEntries.length >= maxChildren) break;
683
+ }
684
+ if (response.Contents) for (const object of response.Contents) {
685
+ if (!object.Key) continue;
686
+ if (object.Key === prefix) continue;
687
+ const fileName = object.Key.slice(prefix.length);
688
+ if (!fileName || fileName.includes("/")) continue;
689
+ const normalizedPath = joinURL("/", basePath, fileName);
690
+ childEntries.push({
691
+ id: this.generateId(object.Key),
692
+ path: normalizedPath,
693
+ updatedAt: object.LastModified,
694
+ meta: {
695
+ size: object.Size,
696
+ lastModified: object.LastModified?.toISOString(),
697
+ etag: object.ETag?.replace(/"/g, ""),
698
+ storageClass: object.StorageClass,
699
+ platformRef: generatePlatformRef(this.options.bucket, this.options.region, object.Key, false)
700
+ }
701
+ });
702
+ if (childEntries.length >= maxChildren) break;
703
+ }
704
+ continuationToken = response.IsTruncated ? response.NextContinuationToken : void 0;
705
+ } while (continuationToken && childEntries.length < maxChildren);
706
+ const selfPath = basePath ? joinURL("/", basePath) : "/";
707
+ if (childEntries.length === 0 && basePath) {
708
+ const key = this.options.prefix ? `${this.options.prefix}/${basePath}` : basePath;
709
+ try {
710
+ const headCommand = new HeadObjectCommand({
711
+ Bucket: this.options.bucket,
712
+ Key: key
713
+ });
714
+ await this.client.send(headCommand);
715
+ return { data: [] };
716
+ } catch (error) {
717
+ if (error?.name === "NotFound" || error?.$metadata?.httpStatusCode === 404) try {
718
+ const dirMarkerCommand = new HeadObjectCommand({
719
+ Bucket: this.options.bucket,
720
+ Key: `${key}/`
721
+ });
722
+ await this.client.send(dirMarkerCommand);
723
+ } catch {
724
+ throw new AFSNotFoundError(selfPath);
725
+ }
726
+ else throw error;
727
+ }
728
+ }
729
+ return { data: childEntries };
730
+ }
731
+ async listVersionsHandler(ctx) {
732
+ try {
733
+ const normalizedPath = ctx.params.path.replace(/^\/+/, "").replace(/\/+$/, "");
734
+ const key = this.options.prefix ? `${this.options.prefix}/${normalizedPath}` : normalizedPath;
735
+ const command = new ListObjectVersionsCommand({
736
+ Bucket: this.options.bucket,
737
+ Prefix: key,
738
+ MaxKeys: 1e3
739
+ });
740
+ const response = await this.client.send(command);
741
+ const entries = [];
742
+ if (response.Versions) {
743
+ for (const version of response.Versions) if (version.Key === key && version.VersionId) {
744
+ const versionPath = joinURL("/", normalizedPath, "@versions", version.VersionId);
745
+ entries.push({
746
+ id: `${this.generateId(key)}:${version.VersionId}`,
747
+ path: versionPath,
748
+ updatedAt: version.LastModified,
749
+ meta: {
750
+ versionId: version.VersionId,
751
+ isLatest: version.IsLatest ?? false,
752
+ lastModified: version.LastModified?.toISOString(),
753
+ size: version.Size ?? 0,
754
+ etag: version.ETag?.replace(/"/g, "")
755
+ }
756
+ });
757
+ }
758
+ }
759
+ return { data: entries };
760
+ } catch (error) {
761
+ throw mapS3Error(error, `/${ctx.params.path}/@versions`);
762
+ }
763
+ }
764
+ async readHandler(ctx) {
765
+ const normalizedOutputPath = ctx.path.startsWith("/") ? ctx.path : `/${ctx.path}`;
766
+ try {
767
+ const key = this.buildS3Key(ctx.params.path);
768
+ if (!key) {
769
+ const listCommand = new ListObjectsV2Command({
770
+ Bucket: this.options.bucket,
771
+ Prefix: this.options.prefix ? `${this.options.prefix}/` : "",
772
+ Delimiter: "/",
773
+ MaxKeys: 1e3
774
+ });
775
+ const listResponse = await this.client.send(listCommand);
776
+ const childrenCount = (listResponse.CommonPrefixes?.length ?? 0) + (listResponse.Contents?.length ?? 0);
777
+ return {
778
+ id: this.generateId("/"),
779
+ path: "/",
780
+ content: "",
781
+ meta: {
782
+ kind: "afs:node",
783
+ childrenCount,
784
+ platformRef: generatePlatformRef(this.options.bucket, this.options.region, "", true)
785
+ }
786
+ };
787
+ }
788
+ try {
789
+ const command = new GetObjectCommand({
790
+ Bucket: this.options.bucket,
791
+ Key: key
792
+ });
793
+ const response = await this.client.send(command);
794
+ if (key.endsWith("/") || response.ContentType === "application/x-directory") return {
795
+ id: this.generateId(key),
796
+ path: normalizedOutputPath,
797
+ content: "",
798
+ updatedAt: response.LastModified,
799
+ meta: {
800
+ kind: "afs:node",
801
+ childrenCount: -1,
802
+ platformRef: generatePlatformRef(this.options.bucket, this.options.region, key, true)
803
+ }
804
+ };
805
+ let content;
806
+ if (response.Body) {
807
+ const bytes = await response.Body.transformToByteArray();
808
+ content = new TextDecoder().decode(bytes);
809
+ } else content = "";
810
+ return {
811
+ id: this.generateId(key),
812
+ path: normalizedOutputPath,
813
+ content,
814
+ updatedAt: response.LastModified,
815
+ meta: {
816
+ size: response.ContentLength,
817
+ mimeType: response.ContentType,
818
+ contentType: response.ContentType,
819
+ contentLength: response.ContentLength,
820
+ lastModified: response.LastModified?.toISOString(),
821
+ etag: response.ETag?.replace(/"/g, ""),
822
+ contentRange: response.ContentRange
823
+ }
824
+ };
825
+ } catch (error) {
826
+ if (error?.name === "NotFound" || error?.$metadata?.httpStatusCode === 404) {
827
+ const listCommand = new ListObjectsV2Command({
828
+ Bucket: this.options.bucket,
829
+ Prefix: `${key}/`,
830
+ Delimiter: "/",
831
+ MaxKeys: 1e3
832
+ });
833
+ const listResponse = await this.client.send(listCommand);
834
+ if (listResponse.Contents && listResponse.Contents.length > 0 || listResponse.CommonPrefixes && listResponse.CommonPrefixes.length > 0) {
835
+ const childrenCount = (listResponse.CommonPrefixes?.length ?? 0) + (listResponse.Contents?.length ?? 0);
836
+ return {
837
+ id: this.generateId(`${key}/`),
838
+ path: normalizedOutputPath,
839
+ content: "",
840
+ meta: {
841
+ kind: "afs:node",
842
+ childrenCount,
843
+ platformRef: generatePlatformRef(this.options.bucket, this.options.region, key, true)
844
+ }
845
+ };
846
+ }
847
+ try {
848
+ const markerCommand = new HeadObjectCommand({
849
+ Bucket: this.options.bucket,
850
+ Key: `${key}/`
851
+ });
852
+ await this.client.send(markerCommand);
853
+ return {
854
+ id: this.generateId(`${key}/`),
855
+ path: normalizedOutputPath,
856
+ content: "",
857
+ meta: {
858
+ kind: "afs:node",
859
+ childrenCount: 0,
860
+ platformRef: generatePlatformRef(this.options.bucket, this.options.region, key, true)
861
+ }
862
+ };
863
+ } catch {
864
+ throw new AFSNotFoundError(normalizedOutputPath);
865
+ }
866
+ }
867
+ throw error;
868
+ }
869
+ } catch (error) {
870
+ if (error instanceof AFSNotFoundError) throw error;
871
+ throw mapS3Error(error, normalizedOutputPath);
872
+ }
873
+ }
874
+ async readVersionHandler(ctx) {
875
+ try {
876
+ const normalizedPath = ctx.params.path.replace(/^\/+/, "").replace(/\/+$/, "");
877
+ const key = this.options.prefix ? `${this.options.prefix}/${normalizedPath}` : normalizedPath;
878
+ const command = new GetObjectCommand({
879
+ Bucket: this.options.bucket,
880
+ Key: key,
881
+ VersionId: ctx.params.versionId
882
+ });
883
+ const response = await this.client.send(command);
884
+ const content = response.Body ? await response.Body.transformToString() : "";
885
+ return {
886
+ id: `${this.generateId(key)}:${ctx.params.versionId}`,
887
+ path: ctx.path,
888
+ content,
889
+ updatedAt: response.LastModified,
890
+ meta: {
891
+ size: response.ContentLength,
892
+ contentType: response.ContentType,
893
+ lastModified: response.LastModified?.toISOString(),
894
+ etag: response.ETag?.replace(/"/g, ""),
895
+ versionId: response.VersionId
896
+ }
897
+ };
898
+ } catch (error) {
899
+ throw mapS3Error(error, `/${ctx.params.path}/@versions/${ctx.params.versionId}`);
900
+ }
901
+ }
902
+ async metaHandler(ctx) {
903
+ try {
904
+ const path = ctx.params.path ?? "";
905
+ const normalizedPath = path.replace(/^\/+/, "").replace(/\/+$/, "");
906
+ const key = this.options.prefix ? normalizedPath ? `${this.options.prefix}/${normalizedPath}` : this.options.prefix : normalizedPath;
907
+ if (!key) {
908
+ const listCommand = new ListObjectsV2Command({
909
+ Bucket: this.options.bucket,
910
+ Prefix: this.options.prefix ? `${this.options.prefix}/` : "",
911
+ Delimiter: "/",
912
+ MaxKeys: 1e3
913
+ });
914
+ const listResponse = await this.client.send(listCommand);
915
+ const childrenCount = (listResponse.CommonPrefixes?.length ?? 0) + (listResponse.Contents?.length ?? 0);
916
+ return {
917
+ id: this.generateId("/"),
918
+ path: "/.meta",
919
+ meta: {
920
+ kind: "afs:node",
921
+ childrenCount,
922
+ platformRef: generatePlatformRef(this.options.bucket, this.options.region, "", true)
923
+ }
924
+ };
925
+ }
926
+ if (this.statCache) {
927
+ const cacheKey = createCacheKey(this.options.bucket, this.options.prefix ?? "", path);
928
+ const cached = this.statCache.get(cacheKey);
929
+ if (cached) return {
930
+ id: cached.id,
931
+ path: ctx.path,
932
+ meta: cached.meta
933
+ };
934
+ }
935
+ try {
936
+ const command = new HeadObjectCommand({
937
+ Bucket: this.options.bucket,
938
+ Key: key
939
+ });
940
+ const response = await this.client.send(command);
941
+ if (key.endsWith("/") || response.ContentType === "application/x-directory") return {
942
+ id: this.generateId(key),
943
+ path: ctx.path,
944
+ updatedAt: response.LastModified,
945
+ meta: {
946
+ kind: "afs:node",
947
+ childrenCount: -1,
948
+ platformRef: generatePlatformRef(this.options.bucket, this.options.region, key, true)
949
+ }
950
+ };
951
+ const result = {
952
+ id: this.generateId(key),
953
+ path: ctx.path,
954
+ updatedAt: response.LastModified,
955
+ meta: {
956
+ kind: "afs:document",
957
+ size: response.ContentLength,
958
+ contentType: response.ContentType,
959
+ lastModified: response.LastModified?.toISOString(),
960
+ etag: response.ETag?.replace(/"/g, ""),
961
+ storageClass: response.StorageClass,
962
+ platformRef: generatePlatformRef(this.options.bucket, this.options.region, key, false)
963
+ }
964
+ };
965
+ if (this.statCache) {
966
+ const cacheKey = createCacheKey(this.options.bucket, this.options.prefix ?? "", path);
967
+ this.statCache.set(cacheKey, result);
968
+ }
969
+ return result;
970
+ } catch (error) {
971
+ if (error?.name === "NotFound" || error?.$metadata?.httpStatusCode === 404) {
972
+ const listCommand = new ListObjectsV2Command({
973
+ Bucket: this.options.bucket,
974
+ Prefix: `${key}/`,
975
+ MaxKeys: 1
976
+ });
977
+ const listResponse = await this.client.send(listCommand);
978
+ if (listResponse.Contents && listResponse.Contents.length > 0) return {
979
+ id: this.generateId(`${key}/`),
980
+ path: ctx.path,
981
+ meta: {
982
+ kind: "afs:node",
983
+ childrenCount: -1,
984
+ platformRef: generatePlatformRef(this.options.bucket, this.options.region, key, true)
985
+ }
986
+ };
987
+ try {
988
+ const markerCommand = new HeadObjectCommand({
989
+ Bucket: this.options.bucket,
990
+ Key: `${key}/`
991
+ });
992
+ const markerResponse = await this.client.send(markerCommand);
993
+ return {
994
+ id: this.generateId(`${key}/`),
995
+ path: ctx.path,
996
+ updatedAt: markerResponse.LastModified,
997
+ meta: {
998
+ kind: "afs:node",
999
+ childrenCount: -1,
1000
+ platformRef: generatePlatformRef(this.options.bucket, this.options.region, key, true)
1001
+ }
1002
+ };
1003
+ } catch {
1004
+ throw new AFSNotFoundError(path.startsWith("/") ? path : `/${path}`);
1005
+ }
1006
+ }
1007
+ throw error;
1008
+ }
1009
+ } catch (error) {
1010
+ if (error instanceof AFSNotFoundError) throw error;
1011
+ throw mapS3Error(error, (ctx.params.path ?? "").startsWith("/") ? ctx.params.path ?? "/" : `/${ctx.params.path ?? ""}`);
1012
+ }
1013
+ }
1014
+ async statHandler(ctx) {
1015
+ const metaEntry = await this.metaHandler({
1016
+ ...ctx,
1017
+ path: ctx.path.endsWith("/.meta") ? ctx.path : `${ctx.path}/.meta`
1018
+ });
1019
+ const pathSegments = ctx.path.split("/").filter(Boolean);
1020
+ return { data: {
1021
+ id: pathSegments.length > 0 ? pathSegments[pathSegments.length - 1] : "/",
1022
+ path: ctx.path,
1023
+ meta: metaEntry.meta
1024
+ } };
1025
+ }
1026
+ async writeHandler(ctx, payload) {
1027
+ try {
1028
+ const key = this.buildS3Key(ctx.params.path);
1029
+ let body;
1030
+ if (typeof payload.content === "string") body = Buffer.from(payload.content, "utf-8");
1031
+ else if (Buffer.isBuffer(payload.content)) body = payload.content;
1032
+ else if (payload.content !== void 0) body = Buffer.from(JSON.stringify(payload.content), "utf-8");
1033
+ else body = Buffer.from("");
1034
+ const contentType = payload.meta?.mimeType ?? payload.meta?.contentType ?? "application/octet-stream";
1035
+ let etag;
1036
+ let versionId;
1037
+ if (shouldUseMultipart(body.length)) {
1038
+ const result = await multipartUpload(this.client, this.options.bucket, key, body, { contentType });
1039
+ etag = result.etag;
1040
+ versionId = result.versionId;
1041
+ } else {
1042
+ const command = new PutObjectCommand({
1043
+ Bucket: this.options.bucket,
1044
+ Key: key,
1045
+ Body: body,
1046
+ ContentType: contentType
1047
+ });
1048
+ const response = await this.client.send(command);
1049
+ etag = response.ETag?.replace(/"/g, "");
1050
+ versionId = response.VersionId;
1051
+ }
1052
+ const normalizedOutputPath = ctx.path.startsWith("/") ? ctx.path : `/${ctx.path}`;
1053
+ this.invalidateCache(ctx.params.path);
1054
+ return { data: {
1055
+ id: this.generateId(key),
1056
+ path: normalizedOutputPath,
1057
+ content: payload.content,
1058
+ meta: {
1059
+ size: body.length,
1060
+ etag,
1061
+ versionId,
1062
+ ...payload.meta
1063
+ }
1064
+ } };
1065
+ } catch (error) {
1066
+ throw mapS3Error(error, ctx.params.path.startsWith("/") ? ctx.params.path : `/${ctx.params.path}`);
1067
+ }
1068
+ }
1069
+ async deleteHandler(ctx) {
1070
+ try {
1071
+ const key = this.buildS3Key(ctx.params.path);
1072
+ try {
1073
+ const headCommand = new HeadObjectCommand({
1074
+ Bucket: this.options.bucket,
1075
+ Key: key
1076
+ });
1077
+ await this.client.send(headCommand);
1078
+ } catch (error) {
1079
+ if (error?.name === "NotFound" || error?.$metadata?.httpStatusCode === 404) throw new AFSNotFoundError(`/${ctx.params.path}`);
1080
+ throw error;
1081
+ }
1082
+ const command = new DeleteObjectCommand({
1083
+ Bucket: this.options.bucket,
1084
+ Key: key
1085
+ });
1086
+ await this.client.send(command);
1087
+ this.invalidateCache(ctx.params.path);
1088
+ return { message: `Successfully deleted: ${ctx.params.path}` };
1089
+ } catch (error) {
1090
+ if (error instanceof AFSNotFoundError) throw error;
1091
+ throw mapS3Error(error, `/${ctx.params.path}`);
1092
+ }
1093
+ }
1094
+ async listActionsHandler(ctx) {
1095
+ return { data: [
1096
+ {
1097
+ id: "select",
1098
+ path: joinURL(ctx.path, ".actions", "select"),
1099
+ summary: "Query with S3 Select",
1100
+ meta: {
1101
+ kind: "afs:executable",
1102
+ kinds: ["afs:executable", "afs:node"],
1103
+ inputSchema: {
1104
+ type: "object",
1105
+ properties: {
1106
+ expression: {
1107
+ type: "string",
1108
+ description: "SQL expression"
1109
+ },
1110
+ inputFormat: {
1111
+ type: "string",
1112
+ description: "CSV | JSON | Parquet"
1113
+ },
1114
+ outputFormat: {
1115
+ type: "string",
1116
+ description: "CSV | JSON"
1117
+ }
1118
+ },
1119
+ required: ["expression"]
1120
+ }
1121
+ }
1122
+ },
1123
+ {
1124
+ id: "presign-download",
1125
+ path: joinURL(ctx.path, ".actions", "presign-download"),
1126
+ summary: "Generate download URL",
1127
+ meta: {
1128
+ kind: "afs:executable",
1129
+ kinds: ["afs:executable", "afs:node"],
1130
+ inputSchema: {
1131
+ type: "object",
1132
+ properties: { expiresIn: {
1133
+ type: "number",
1134
+ description: "Expiration in seconds (default: 3600, max: 604800)"
1135
+ } }
1136
+ }
1137
+ }
1138
+ },
1139
+ {
1140
+ id: "presign-upload",
1141
+ path: joinURL(ctx.path, ".actions", "presign-upload"),
1142
+ summary: "Generate upload URL",
1143
+ meta: {
1144
+ kind: "afs:executable",
1145
+ kinds: ["afs:executable", "afs:node"],
1146
+ inputSchema: {
1147
+ type: "object",
1148
+ properties: {
1149
+ expiresIn: {
1150
+ type: "number",
1151
+ description: "Expiration in seconds"
1152
+ },
1153
+ contentType: {
1154
+ type: "string",
1155
+ description: "Content-Type for upload"
1156
+ }
1157
+ }
1158
+ }
1159
+ }
1160
+ }
1161
+ ] };
1162
+ }
1163
+ async selectActionHandler(ctx, args) {
1164
+ const expression = args.expression;
1165
+ if (!expression) throw new AFSError("Missing required argument: expression", "AFS_INVALID_ARGUMENT");
1166
+ const normalizedPath = ctx.params.path.replace(/^\/+/, "").replace(/\/+$/, "");
1167
+ const key = this.options.prefix ? `${this.options.prefix}/${normalizedPath}` : normalizedPath;
1168
+ const result = await selectQuery(this.client, this.options.bucket, "", key, expression, {
1169
+ inputFormat: args.inputFormat,
1170
+ outputFormat: args.outputFormat
1171
+ });
1172
+ return {
1173
+ success: true,
1174
+ data: {
1175
+ records: result.records,
1176
+ stats: result.stats
1177
+ }
1178
+ };
1179
+ }
1180
+ async presignDownloadActionHandler(ctx, args) {
1181
+ const normalizedPath = ctx.params.path.replace(/^\/+/, "").replace(/\/+$/, "");
1182
+ const key = this.options.prefix ? `${this.options.prefix}/${normalizedPath}` : normalizedPath;
1183
+ let expiresIn = args.expiresIn ?? DEFAULT_EXPIRES_IN;
1184
+ if (expiresIn > MAX_EXPIRES_IN) expiresIn = MAX_EXPIRES_IN;
1185
+ if (expiresIn < 1) expiresIn = 1;
1186
+ const command = new GetObjectCommand({
1187
+ Bucket: this.options.bucket,
1188
+ Key: key
1189
+ });
1190
+ return {
1191
+ success: true,
1192
+ data: {
1193
+ url: await getSignedUrl(this.client, command, { expiresIn }),
1194
+ expiresAt: new Date(Date.now() + expiresIn * 1e3).toISOString()
1195
+ }
1196
+ };
1197
+ }
1198
+ async presignUploadActionHandler(ctx, args) {
1199
+ const normalizedPath = ctx.params.path.replace(/^\/+/, "").replace(/\/+$/, "");
1200
+ const key = this.options.prefix ? `${this.options.prefix}/${normalizedPath}` : normalizedPath;
1201
+ let expiresIn = args.expiresIn ?? DEFAULT_EXPIRES_IN;
1202
+ if (expiresIn > MAX_EXPIRES_IN) expiresIn = MAX_EXPIRES_IN;
1203
+ if (expiresIn < 1) expiresIn = 1;
1204
+ const command = new PutObjectCommand({
1205
+ Bucket: this.options.bucket,
1206
+ Key: key,
1207
+ ContentType: args.contentType ?? "application/octet-stream"
1208
+ });
1209
+ return {
1210
+ success: true,
1211
+ data: {
1212
+ url: await getSignedUrl(this.client, command, { expiresIn }),
1213
+ expiresAt: new Date(Date.now() + expiresIn * 1e3).toISOString()
1214
+ }
1215
+ };
1216
+ }
1217
+ async explainHandler(ctx) {
1218
+ const normalizedPath = (ctx.params.path ?? "").replace(/^\/+/, "").replace(/\/+$/, "");
1219
+ if (!normalizedPath) {
1220
+ const response$1 = await this.client.send(new ListObjectsV2Command({
1221
+ Bucket: this.options.bucket,
1222
+ Prefix: this.options.prefix ? `${this.options.prefix}/` : void 0,
1223
+ Delimiter: "/",
1224
+ MaxKeys: 1e3
1225
+ }));
1226
+ const objectCount$1 = response$1.Contents?.length ?? 0;
1227
+ const prefixCount$1 = response$1.CommonPrefixes?.length ?? 0;
1228
+ const lines$1 = [];
1229
+ lines$1.push(`# ${this.options.bucket}`);
1230
+ lines$1.push("");
1231
+ lines$1.push(`- **Type**: S3 Bucket`);
1232
+ lines$1.push(`- **Bucket**: ${this.options.bucket}`);
1233
+ if (this.options.prefix) lines$1.push(`- **Prefix**: ${this.options.prefix}`);
1234
+ if (this.options.region) lines$1.push(`- **Region**: ${this.options.region}`);
1235
+ lines$1.push(`- **Access Mode**: ${this.accessMode}`);
1236
+ lines$1.push(`- **Top-level Objects**: ${objectCount$1}`);
1237
+ lines$1.push(`- **Top-level Prefixes**: ${prefixCount$1}`);
1238
+ return {
1239
+ format: "markdown",
1240
+ content: lines$1.join("\n")
1241
+ };
1242
+ }
1243
+ const key = this.buildS3Key(normalizedPath);
1244
+ try {
1245
+ const head = await this.client.send(new HeadObjectCommand({
1246
+ Bucket: this.options.bucket,
1247
+ Key: key
1248
+ }));
1249
+ const lines$1 = [];
1250
+ lines$1.push(`# ${normalizedPath}`);
1251
+ lines$1.push("");
1252
+ lines$1.push(`- **Type**: Object`);
1253
+ lines$1.push(`- **Key**: ${key}`);
1254
+ lines$1.push(`- **Size**: ${head.ContentLength ?? 0} bytes`);
1255
+ if (head.ContentType) lines$1.push(`- **Content-Type**: ${head.ContentType}`);
1256
+ if (head.StorageClass) lines$1.push(`- **Storage Class**: ${head.StorageClass}`);
1257
+ if (head.LastModified) lines$1.push(`- **Last Modified**: ${head.LastModified.toISOString()}`);
1258
+ if (head.ETag) lines$1.push(`- **ETag**: ${head.ETag}`);
1259
+ return {
1260
+ format: "markdown",
1261
+ content: lines$1.join("\n")
1262
+ };
1263
+ } catch {}
1264
+ const prefix = key.endsWith("/") ? key : `${key}/`;
1265
+ const response = await this.client.send(new ListObjectsV2Command({
1266
+ Bucket: this.options.bucket,
1267
+ Prefix: prefix,
1268
+ Delimiter: "/",
1269
+ MaxKeys: 1e3
1270
+ }));
1271
+ const objectCount = response.Contents?.length ?? 0;
1272
+ const prefixCount = response.CommonPrefixes?.length ?? 0;
1273
+ if (objectCount === 0 && prefixCount === 0) throw new AFSNotFoundError(`/${normalizedPath}`, `Path not found: ${normalizedPath}`);
1274
+ const lines = [];
1275
+ lines.push(`# ${normalizedPath}/`);
1276
+ lines.push("");
1277
+ lines.push(`- **Type**: Prefix (directory)`);
1278
+ lines.push(`- **Prefix**: ${prefix}`);
1279
+ lines.push(`- **Objects**: ${objectCount}`);
1280
+ lines.push(`- **Sub-prefixes**: ${prefixCount}`);
1281
+ return {
1282
+ format: "markdown",
1283
+ content: lines.join("\n")
1284
+ };
1285
+ }
1286
+ async searchHandler(ctx, query) {
1287
+ const { minimatch } = await import("minimatch");
1288
+ const normalizedPath = (ctx.params.path ?? "").replace(/^\/+/, "").replace(/\/+$/, "");
1289
+ const prefix = normalizedPath ? this.options.prefix ? `${this.options.prefix}/${normalizedPath}/` : `${normalizedPath}/` : this.options.prefix ? `${this.options.prefix}/` : "";
1290
+ const results = [];
1291
+ let continuationToken;
1292
+ do {
1293
+ const response = await this.client.send(new ListObjectsV2Command({
1294
+ Bucket: this.options.bucket,
1295
+ Prefix: prefix || void 0,
1296
+ ContinuationToken: continuationToken,
1297
+ MaxKeys: 1e3
1298
+ }));
1299
+ for (const obj of response.Contents ?? []) {
1300
+ if (!obj.Key) continue;
1301
+ const relativePath = prefix ? obj.Key.slice(prefix.length) : obj.Key;
1302
+ if (!relativePath) continue;
1303
+ if (minimatch(relativePath, query)) {
1304
+ const displayPath = joinURL("/", normalizedPath, relativePath);
1305
+ results.push({
1306
+ id: this.generateId(obj.Key),
1307
+ path: displayPath,
1308
+ meta: {
1309
+ size: obj.Size,
1310
+ lastModified: obj.LastModified?.toISOString(),
1311
+ etag: obj.ETag?.replace(/"/g, "")
1312
+ }
1313
+ });
1314
+ }
1315
+ }
1316
+ continuationToken = response.IsTruncated ? response.NextContinuationToken : void 0;
1317
+ } while (continuationToken);
1318
+ return { data: results };
1319
+ }
1320
+ async readCapabilities(_ctx) {
1321
+ const capabilities = {
1322
+ schemaVersion: 1,
1323
+ provider: "s3",
1324
+ description: `S3 bucket: ${this.options.bucket}`,
1325
+ tools: [],
1326
+ operations: this.getOperationsDeclaration(),
1327
+ actions: [{
1328
+ description: "S3 object actions",
1329
+ catalog: [
1330
+ {
1331
+ name: "select",
1332
+ description: "Query object contents using S3 Select (CSV/JSON/Parquet)",
1333
+ inputSchema: {
1334
+ type: "object",
1335
+ properties: {
1336
+ expression: {
1337
+ type: "string",
1338
+ description: "SQL expression"
1339
+ },
1340
+ inputFormat: {
1341
+ type: "string",
1342
+ description: "CSV | JSON | Parquet"
1343
+ },
1344
+ outputFormat: {
1345
+ type: "string",
1346
+ description: "CSV | JSON"
1347
+ }
1348
+ },
1349
+ required: ["expression"]
1350
+ }
1351
+ },
1352
+ {
1353
+ name: "presign-download",
1354
+ description: "Generate a pre-signed download URL",
1355
+ inputSchema: {
1356
+ type: "object",
1357
+ properties: { expiresIn: {
1358
+ type: "number",
1359
+ description: "Expiration in seconds (default: 3600, max: 604800)"
1360
+ } }
1361
+ }
1362
+ },
1363
+ {
1364
+ name: "presign-upload",
1365
+ description: "Generate a pre-signed upload URL",
1366
+ inputSchema: {
1367
+ type: "object",
1368
+ properties: {
1369
+ expiresIn: {
1370
+ type: "number",
1371
+ description: "Expiration in seconds"
1372
+ },
1373
+ contentType: {
1374
+ type: "string",
1375
+ description: "Content-Type for upload"
1376
+ }
1377
+ }
1378
+ }
1379
+ }
1380
+ ],
1381
+ discovery: { pathTemplate: "/{path}/.actions" }
1382
+ }]
1383
+ };
1384
+ return {
1385
+ id: ".capabilities",
1386
+ path: "/.meta/.capabilities",
1387
+ content: JSON.stringify(capabilities, null, 2),
1388
+ meta: { kind: "afs:capabilities" }
1389
+ };
1390
+ }
1391
+ };
1392
+ __decorate([List("/"), List("/:path*")], AFSS3.prototype, "listHandler", null);
1393
+ __decorate([List("/:path*/@versions")], AFSS3.prototype, "listVersionsHandler", null);
1394
+ __decorate([Read("/:path*")], AFSS3.prototype, "readHandler", null);
1395
+ __decorate([Read("/:path*/@versions/:versionId")], AFSS3.prototype, "readVersionHandler", null);
1396
+ __decorate([Meta("/"), Meta("/:path*")], AFSS3.prototype, "metaHandler", null);
1397
+ __decorate([Stat("/"), Stat("/:path*")], AFSS3.prototype, "statHandler", null);
1398
+ __decorate([Write("/:path*")], AFSS3.prototype, "writeHandler", null);
1399
+ __decorate([Delete("/:path*")], AFSS3.prototype, "deleteHandler", null);
1400
+ __decorate([Actions("/:path*")], AFSS3.prototype, "listActionsHandler", null);
1401
+ __decorate([Actions.Exec("/:path*", "select")], AFSS3.prototype, "selectActionHandler", null);
1402
+ __decorate([Actions.Exec("/:path*", "presign-download")], AFSS3.prototype, "presignDownloadActionHandler", null);
1403
+ __decorate([Actions.Exec("/:path*", "presign-upload")], AFSS3.prototype, "presignUploadActionHandler", null);
1404
+ __decorate([Explain("/"), Explain("/:path*")], AFSS3.prototype, "explainHandler", null);
1405
+ __decorate([Search("/"), Search("/:path*")], AFSS3.prototype, "searchHandler", null);
1406
+ __decorate([Read("/.meta/.capabilities")], AFSS3.prototype, "readCapabilities", null);
1407
+
1408
+ //#endregion
1409
+ export { AFSS3, AFSS3 as default };
1410
+ //# sourceMappingURL=index.mjs.map