Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

git confuses me sometimes

+8676
+136
packages/@wispplace/tiered-storage/.env.example
··· 1 + # Tiered Storage Configuration 2 + # Copy this file to .env and configure for your environment 3 + 4 + # ============================================================================ 5 + # S3 Configuration (Cold Tier - Required) 6 + # ============================================================================ 7 + 8 + # AWS S3 bucket name (or S3-compatible bucket) 9 + S3_BUCKET=tiered-storage-cache 10 + 11 + # Optional: Separate bucket for metadata (RECOMMENDED for production!) 12 + # When set, metadata is stored as separate JSON objects instead of S3 object metadata. 13 + # This allows fast, cheap metadata updates without copying entire objects. 14 + # Leave blank to store metadata in S3 object metadata fields (slower, more expensive). 15 + S3_METADATA_BUCKET=tiered-storage-metadata 16 + 17 + # AWS region 18 + S3_REGION=us-east-1 19 + 20 + # S3 endpoint (optional - for S3-compatible services like R2, Minio) 21 + # Leave blank for AWS S3 22 + # For Cloudflare R2: https://YOUR-ACCOUNT-ID.r2.cloudflarestorage.com 23 + # For MinIO: http://localhost:9000 24 + # For other S3-compatible: https://s3.your-service.com 25 + # S3_ENDPOINT= 26 + 27 + # Force path-style URLs (usually needed for S3-compatible services) 28 + # Default: true (recommended for most S3-compatible services) 29 + # Set to false only if your service requires virtual-host-style URLs 30 + # S3_FORCE_PATH_STYLE=true 31 + 32 + # AWS credentials 33 + # If not provided, uses default AWS credential chain 34 + # (environment variables, ~/.aws/credentials, IAM roles, etc.) 35 + AWS_ACCESS_KEY_ID=your_access_key_id 36 + AWS_SECRET_ACCESS_KEY=your_secret_access_key 37 + 38 + # ============================================================================ 39 + # Cloudflare R2 Example Configuration 40 + # ============================================================================ 41 + # Uncomment these to use Cloudflare R2 instead of AWS S3: 42 + # 43 + # S3_BUCKET=my-r2-bucket 44 + # S3_METADATA_BUCKET=my-r2-metadata-bucket 45 + # S3_REGION=auto 46 + # S3_ENDPOINT=https://YOUR-ACCOUNT-ID.r2.cloudflarestorage.com 47 + # AWS_ACCESS_KEY_ID=your_r2_access_key_id 48 + # AWS_SECRET_ACCESS_KEY=your_r2_secret_access_key 49 + 50 + # ============================================================================ 51 + # Memory Tier Configuration (Hot) 52 + # ============================================================================ 53 + 54 + # Maximum size in bytes for hot (memory) tier 55 + # Default: 100MB 56 + MEMORY_MAX_SIZE_BYTES=104857600 57 + 58 + # Maximum number of items in hot tier 59 + # Optional - if not set, only size limit applies 60 + MEMORY_MAX_ITEMS=1000 61 + 62 + # ============================================================================ 63 + # Disk Tier Configuration (Warm) 64 + # ============================================================================ 65 + 66 + # Directory for warm tier cache 67 + # Default: ./cache/warm 68 + DISK_WARM_DIRECTORY=./cache/warm 69 + 70 + # Maximum size in bytes for warm tier 71 + # Optional - if not set, no size limit 72 + DISK_WARM_MAX_SIZE_BYTES=10737418240 73 + 74 + # Eviction policy when size limit reached 75 + # Options: lru, fifo, size 76 + # Default: lru 77 + DISK_WARM_EVICTION_POLICY=lru 78 + 79 + # ============================================================================ 80 + # Storage Options 81 + # ============================================================================ 82 + 83 + # Enable compression (gzip) 84 + # Default: false 85 + COMPRESSION_ENABLED=true 86 + 87 + # Default TTL in milliseconds 88 + # Optional - if not set, data never expires 89 + # Example: 1209600000 = 14 days 90 + DEFAULT_TTL_MS=1209600000 91 + 92 + # Promotion strategy: 'eager' or 'lazy' 93 + # eager: Automatically promote data to upper tiers on read 94 + # lazy: Only promote on explicit bootstrap or write 95 + # Default: lazy 96 + PROMOTION_STRATEGY=lazy 97 + 98 + # ============================================================================ 99 + # Bootstrap Configuration 100 + # ============================================================================ 101 + 102 + # Number of items to load into hot tier on bootstrap 103 + # Optional - if not set, loads all items 104 + BOOTSTRAP_HOT_LIMIT=1000 105 + 106 + # Number of days to look back when bootstrapping warm tier 107 + # Example: 7 = only load items accessed in last 7 days 108 + BOOTSTRAP_WARM_DAYS=7 109 + 110 + # Maximum items to load into warm tier on bootstrap 111 + # Optional - if not set, loads all matching items 112 + BOOTSTRAP_WARM_LIMIT=10000 113 + 114 + # ============================================================================ 115 + # Performance Tuning 116 + # ============================================================================ 117 + 118 + # Maximum concurrent operations for bootstrap 119 + # Default: 10 120 + BOOTSTRAP_CONCURRENCY=10 121 + 122 + # Timeout for tier operations in milliseconds 123 + # Default: 30000 (30 seconds) 124 + TIER_OPERATION_TIMEOUT_MS=30000 125 + 126 + # ============================================================================ 127 + # Monitoring & Observability 128 + # ============================================================================ 129 + 130 + # Enable statistics tracking 131 + # Default: true 132 + STATS_ENABLED=true 133 + 134 + # Log level: debug, info, warn, error 135 + # Default: info 136 + LOG_LEVEL=info
+17
packages/@wispplace/tiered-storage/.gitignore
··· 1 + node_modules/ 2 + dist/ 3 + .env 4 + *.log 5 + .DS_Store 6 + 7 + # Example cache directories 8 + example-cache/* 9 + example-cache/ 10 + cache/* 11 + cache/ 12 + 13 + # Test cache directories 14 + test-cache/ 15 + 16 + # Build artifacts 17 + *.tsbuildinfo
+9
packages/@wispplace/tiered-storage/.prettierrc.json
··· 1 + { 2 + "useTabs": true, 3 + "tabWidth": 4, 4 + "singleQuote": true, 5 + "trailingComma": "all", 6 + "printWidth": 100, 7 + "arrowParens": "always", 8 + "endOfLine": "lf" 9 + }
+256
packages/@wispplace/tiered-storage/README.md
··· 1 + # tiered-storage 2 + 3 + Cascading cache that flows hot → warm → cold. Memory, disk, S3—or bring your own. 4 + 5 + ## Features 6 + 7 + - **Cascading writes** - data flows down through all tiers 8 + - **Bubbling reads** - check hot first, fall back to warm, then cold 9 + - **Pluggable backends** - memory, disk, S3, or implement your own 10 + - **Selective placement** - skip tiers for big files that don't need memory caching 11 + - **Prefix invalidation** - `invalidate('user:')` nukes all user keys 12 + - **Optional compression** - transparent gzip 13 + 14 + ## Install 15 + 16 + ```bash 17 + npm install tiered-storage 18 + ``` 19 + 20 + ## Example 21 + 22 + ```typescript 23 + import { TieredStorage, MemoryStorageTier, DiskStorageTier, S3StorageTier } from 'tiered-storage' 24 + 25 + const storage = new TieredStorage({ 26 + tiers: { 27 + hot: new MemoryStorageTier({ maxSizeBytes: 100 * 1024 * 1024 }), 28 + warm: new DiskStorageTier({ directory: './cache' }), 29 + cold: new S3StorageTier({ bucket: 'my-bucket', region: 'us-east-1' }), 30 + }, 31 + placementRules: [ 32 + { pattern: '**/index.html', tiers: ['hot', 'warm', 'cold'] }, 33 + { pattern: '**/*.{jpg,png,gif,mp4}', tiers: ['warm', 'cold'] }, 34 + { pattern: '**', tiers: ['warm', 'cold'] }, 35 + ], 36 + }) 37 + 38 + // just set - rules decide where it goes 39 + await storage.set('site:abc/index.html', indexHtml) // → hot + warm + cold 40 + await storage.set('site:abc/hero.png', imageData) // → warm + cold 41 + await storage.set('site:abc/video.mp4', videoData) // → warm + cold 42 + 43 + // reads bubble up from wherever it lives 44 + const page = await storage.getWithMetadata('site:abc/index.html') 45 + console.log(page.source) // 'hot' 46 + 47 + const video = await storage.getWithMetadata('site:abc/video.mp4') 48 + console.log(video.source) // 'warm' 49 + 50 + // nuke entire site 51 + await storage.invalidate('site:abc/') 52 + ``` 53 + 54 + Hot tier stays small and fast. Warm tier has everything. Cold tier is the source of truth. 55 + 56 + ## How it works 57 + 58 + ``` 59 + ┌─────────────────────────────────────────────┐ 60 + │ Cold (S3) - source of truth, all data │ 61 + │ ↑ │ 62 + │ Warm (disk) - everything hot has + more │ 63 + │ ↑ │ 64 + │ Hot (memory) - just the hottest stuff │ 65 + └─────────────────────────────────────────────┘ 66 + ``` 67 + 68 + Writes cascade **down**. Reads bubble **up**. 69 + 70 + ## Eviction 71 + 72 + Items leave upper tiers through eviction or TTL expiration: 73 + 74 + ```typescript 75 + const storage = new TieredStorage({ 76 + tiers: { 77 + // hot: LRU eviction when size/count limits hit 78 + hot: new MemoryStorageTier({ 79 + maxSizeBytes: 100 * 1024 * 1024, 80 + maxItems: 500, 81 + }), 82 + 83 + // warm: evicts when maxSizeBytes hit, policy controls which items go 84 + warm: new DiskStorageTier({ 85 + directory: './cache', 86 + maxSizeBytes: 10 * 1024 * 1024 * 1024, 87 + evictionPolicy: 'lru', // 'lru' | 'fifo' | 'size' 88 + }), 89 + 90 + // cold: never evicts, keeps everything 91 + cold: new S3StorageTier({ bucket: 'my-bucket', region: 'us-east-1' }), 92 + }, 93 + defaultTTL: 14 * 24 * 60 * 60 * 1000, // TTL checked on read 94 + }) 95 + ``` 96 + 97 + A file that hasn't been accessed eventually gets evicted from hot (LRU), then warm (size limit + policy). Next request fetches from cold and promotes it back up. 98 + 99 + ## Placement rules 100 + 101 + Define once which keys go where, instead of passing `skipTiers` on every `set()`: 102 + 103 + ```typescript 104 + const storage = new TieredStorage({ 105 + tiers: { 106 + hot: new MemoryStorageTier({ maxSizeBytes: 50 * 1024 * 1024 }), 107 + warm: new DiskStorageTier({ directory: './cache' }), 108 + cold: new S3StorageTier({ bucket: 'my-bucket', region: 'us-east-1' }), 109 + }, 110 + placementRules: [ 111 + // index.html goes everywhere for instant serving 112 + { pattern: '**/index.html', tiers: ['hot', 'warm', 'cold'] }, 113 + 114 + // images and video skip hot 115 + { pattern: '**/*.{jpg,png,gif,webp,mp4}', tiers: ['warm', 'cold'] }, 116 + 117 + // assets directory skips hot 118 + { pattern: 'assets/**', tiers: ['warm', 'cold'] }, 119 + 120 + // everything else: warm + cold only 121 + { pattern: '**', tiers: ['warm', 'cold'] }, 122 + ], 123 + }) 124 + 125 + // just call set() - rules handle placement 126 + await storage.set('site:abc/index.html', html) // → hot + warm + cold 127 + await storage.set('site:abc/hero.png', image) // → warm + cold 128 + await storage.set('site:abc/assets/font.woff', font) // → warm + cold 129 + await storage.set('site:abc/about.html', html) // → warm + cold 130 + ``` 131 + 132 + Rules are evaluated in order. First match wins. Cold is always included. 133 + 134 + ## API 135 + 136 + ### `storage.get(key)` 137 + 138 + Get data. Returns `null` if missing or expired. 139 + 140 + ### `storage.getWithMetadata(key)` 141 + 142 + Get data plus which tier served it. 143 + 144 + ### `storage.set(key, data, options?)` 145 + 146 + Store data. Options: 147 + 148 + ```typescript 149 + { 150 + ttl: 86400000, // custom TTL 151 + skipTiers: ['hot'], // skip specific tiers 152 + metadata: { ... }, // custom metadata 153 + } 154 + ``` 155 + 156 + ### `storage.delete(key)` 157 + 158 + Delete from all tiers. 159 + 160 + ### `storage.invalidate(prefix)` 161 + 162 + Delete all keys matching prefix. Returns count. 163 + 164 + ### `storage.touch(key, ttl?)` 165 + 166 + Renew TTL. 167 + 168 + ### `storage.listKeys(prefix?)` 169 + 170 + Async iterator over keys. 171 + 172 + ### `storage.getStats()` 173 + 174 + Stats across all tiers. 175 + 176 + ### `storage.bootstrapHot(limit?)` 177 + 178 + Warm up hot tier from warm tier. Run on startup. 179 + 180 + ### `storage.bootstrapWarm(options?)` 181 + 182 + Warm up warm tier from cold tier. 183 + 184 + ## Built-in tiers 185 + 186 + ### MemoryStorageTier 187 + 188 + ```typescript 189 + new MemoryStorageTier({ 190 + maxSizeBytes: 100 * 1024 * 1024, 191 + maxItems: 1000, 192 + }) 193 + ``` 194 + 195 + LRU eviction. Fast. Single process only. 196 + 197 + ### DiskStorageTier 198 + 199 + ```typescript 200 + new DiskStorageTier({ 201 + directory: './cache', 202 + maxSizeBytes: 10 * 1024 * 1024 * 1024, 203 + evictionPolicy: 'lru', // or 'fifo', 'size' 204 + }) 205 + ``` 206 + 207 + Files on disk with `.meta` sidecars. 208 + 209 + ### S3StorageTier 210 + 211 + ```typescript 212 + new S3StorageTier({ 213 + bucket: 'data', 214 + metadataBucket: 'metadata', // recommended! 215 + region: 'us-east-1', 216 + }) 217 + ``` 218 + 219 + Works with AWS S3, Cloudflare R2, MinIO. Use a separate metadata bucket—otherwise updating access counts requires copying entire objects. 220 + 221 + ## Custom tiers 222 + 223 + Implement `StorageTier`: 224 + 225 + ```typescript 226 + interface StorageTier { 227 + get(key: string): Promise<Uint8Array | null> 228 + set(key: string, data: Uint8Array, metadata: StorageMetadata): Promise<void> 229 + delete(key: string): Promise<void> 230 + exists(key: string): Promise<boolean> 231 + listKeys(prefix?: string): AsyncIterableIterator<string> 232 + deleteMany(keys: string[]): Promise<void> 233 + getMetadata(key: string): Promise<StorageMetadata | null> 234 + setMetadata(key: string, metadata: StorageMetadata): Promise<void> 235 + getStats(): Promise<TierStats> 236 + clear(): Promise<void> 237 + 238 + // Optional: combine get + getMetadata for better performance 239 + getWithMetadata?(key: string): Promise<{ data: Uint8Array; metadata: StorageMetadata } | null> 240 + } 241 + ``` 242 + 243 + The optional `getWithMetadata` method returns both data and metadata in a single call. Implement it if your backend can fetch both efficiently (e.g., parallel I/O, single query). Falls back to separate `get()` + `getMetadata()` calls if not implemented. 244 + 245 + ## Running the demo 246 + 247 + ```bash 248 + cp .env.example .env # add S3 creds 249 + bun run serve 250 + ``` 251 + 252 + Visit http://localhost:3000 to see it work. Check http://localhost:3000/admin/stats for live cache stats. 253 + 254 + ## License 255 + 256 + MIT
+27
packages/@wispplace/tiered-storage/agents.md
··· 1 + You are working on a project that stores cold objects in s3, warm objects on disk, and hot objects in memory. It serves APIs for library consumers to use. 2 + 3 + ## Package management 4 + - Use bun for package management (bun install) 5 + - Use npm run test to test 6 + - Use npm run lint to lint 7 + - Use npm run check to typecheck 8 + 9 + Please test and typecheck always whenever you think you are done. 10 + 11 + ## Code style 12 + - Use tabs for indentation, spaces allowed for diagrams in comments 13 + - Use single quotes and add trailing commas 14 + - Use template literals for user-facing strings and error messages 15 + 16 + ## Commenting 17 + Add JSDoc comments to all new exported functions, methods, classes, fields, and enums 18 + JSDoc should include proper annotations: 19 + - use @param for parameters (no dashes after param names) 20 + - use @returns for return values 21 + - use @throws for exceptions when applicable 22 + - keep descriptions concise but informative 23 + 24 + ## Misc 25 + the .research/ directory serves as a workspace for temporary experiments, analysis, and planning materials. create it if necessary (it's gitignored). this directory may contain cloned repositories or other reference materials that can help inform implementation decisions 26 + 27 + **don't make assumptions or speculate about code, plans, or requirements without exploring first; pause and ask for clarification when you're still unsure after looking into it**
+840
packages/@wispplace/tiered-storage/bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "configVersion": 1, 4 + "workspaces": { 5 + "": { 6 + "name": "tiered-storage", 7 + "dependencies": { 8 + "@aws-sdk/client-s3": "^3.500.0", 9 + "@aws-sdk/lib-storage": "^3.500.0", 10 + "hono": "^4.10.7", 11 + "mime-types": "^3.0.2", 12 + "tiny-lru": "^11.0.0", 13 + }, 14 + "devDependencies": { 15 + "@types/bun": "^1.3.4", 16 + "@types/mime-types": "^3.0.1", 17 + "@types/node": "^24.10.1", 18 + "@typescript-eslint/eslint-plugin": "^8.48.1", 19 + "@typescript-eslint/parser": "^8.48.1", 20 + "eslint": "^9.39.1", 21 + "eslint-config-prettier": "^10.1.8", 22 + "eslint-plugin-prettier": "^5.5.4", 23 + "prettier": "^3.7.4", 24 + "tsx": "^4.0.0", 25 + "typescript": "^5.3.0", 26 + "typescript-eslint": "^8.50.0", 27 + "vitest": "^4.0.15", 28 + }, 29 + }, 30 + }, 31 + "packages": { 32 + "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], 33 + 34 + "@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="], 35 + 36 + "@aws-crypto/sha1-browser": ["@aws-crypto/sha1-browser@5.2.0", "", { "dependencies": { "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg=="], 37 + 38 + "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], 39 + 40 + "@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], 41 + 42 + "@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="], 43 + 44 + "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], 45 + 46 + "@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.946.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.946.0", "@aws-sdk/credential-provider-node": "3.946.0", "@aws-sdk/middleware-bucket-endpoint": "3.936.0", "@aws-sdk/middleware-expect-continue": "3.936.0", "@aws-sdk/middleware-flexible-checksums": "3.946.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-location-constraint": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-sdk-s3": "3.946.0", "@aws-sdk/middleware-ssec": "3.936.0", "@aws-sdk/middleware-user-agent": "3.946.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/signature-v4-multi-region": "3.946.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.946.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.7", "@smithy/eventstream-serde-browser": "^4.2.5", "@smithy/eventstream-serde-config-resolver": "^4.3.5", "@smithy/eventstream-serde-node": "^4.2.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-blob-browser": "^4.2.6", "@smithy/hash-node": "^4.2.5", "@smithy/hash-stream-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/md5-js": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.14", "@smithy/middleware-retry": "^4.4.14", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.10", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.13", "@smithy/util-defaults-mode-node": "^4.2.16", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "@smithy/util-waiter": "^4.2.5", "tslib": "^2.6.2" } }, "sha512-Y3ww3yd1wzmS2r3qgH3jg4MxCTdeNrae2J1BmdV+IW/2R2gFWJva5U5GbS6KUSUxanJBRG7gd8uOIi1b0EMOng=="], 47 + 48 + "@aws-sdk/client-sso": ["@aws-sdk/client-sso@3.946.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.946.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-user-agent": "3.946.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.946.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.7", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.14", "@smithy/middleware-retry": "^4.4.14", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.10", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.13", "@smithy/util-defaults-mode-node": "^4.2.16", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-kGAs5iIVyUz4p6TX3pzG5q3cNxXnVpC4pwRC6DCSaSv9ozyPjc2d74FsK4fZ+J+ejtvCdJk72uiuQtWJc86Wuw=="], 49 + 50 + "@aws-sdk/core": ["@aws-sdk/core@3.946.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@aws-sdk/xml-builder": "3.930.0", "@smithy/core": "^3.18.7", "@smithy/node-config-provider": "^4.3.5", "@smithy/property-provider": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/signature-v4": "^5.3.5", "@smithy/smithy-client": "^4.9.10", "@smithy/types": "^4.9.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-u2BkbLLVbMFrEiXrko2+S6ih5sUZPlbVyRPtXOqMHlCyzr70sE8kIiD6ba223rQeIFPcYfW/wHc6k4ihW2xxVg=="], 51 + 52 + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.946.0", "", { "dependencies": { "@aws-sdk/core": "3.946.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-P4l+K6wX1tf8LmWUvZofdQ+BgCNyk6Tb9u1H10npvqpuCD+dCM4pXIBq3PQcv/juUBOvLGGREo+Govuh3lfD0Q=="], 53 + 54 + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.946.0", "", { "dependencies": { "@aws-sdk/core": "3.946.0", "@aws-sdk/types": "3.936.0", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/node-http-handler": "^4.4.5", "@smithy/property-provider": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.10", "@smithy/types": "^4.9.0", "@smithy/util-stream": "^4.5.6", "tslib": "^2.6.2" } }, "sha512-/zeOJ6E7dGZQ/l2k7KytEoPJX0APIhwt0A79hPf/bUpMF4dDs2P6JmchDrotk0a0Y/MIdNF8sBQ/MEOPnBiYoQ=="], 55 + 56 + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.946.0", "", { "dependencies": { "@aws-sdk/core": "3.946.0", "@aws-sdk/credential-provider-env": "3.946.0", "@aws-sdk/credential-provider-http": "3.946.0", "@aws-sdk/credential-provider-login": "3.946.0", "@aws-sdk/credential-provider-process": "3.946.0", "@aws-sdk/credential-provider-sso": "3.946.0", "@aws-sdk/credential-provider-web-identity": "3.946.0", "@aws-sdk/nested-clients": "3.946.0", "@aws-sdk/types": "3.936.0", "@smithy/credential-provider-imds": "^4.2.5", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-Pdgcra3RivWj/TuZmfFaHbqsvvgnSKO0CxlRUMMr0PgBiCnUhyl+zBktdNOeGsOPH2fUzQpYhcUjYUgVSdcSDQ=="], 57 + 58 + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.946.0", "", { "dependencies": { "@aws-sdk/core": "3.946.0", "@aws-sdk/nested-clients": "3.946.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-5iqLNc15u2Zx+7jOdQkIbP62N7n2031tw5hkmIG0DLnozhnk64osOh2CliiOE9x3c4P9Pf4frAwgyy9GzNTk2g=="], 59 + 60 + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.946.0", "", { "dependencies": { "@aws-sdk/credential-provider-env": "3.946.0", "@aws-sdk/credential-provider-http": "3.946.0", "@aws-sdk/credential-provider-ini": "3.946.0", "@aws-sdk/credential-provider-process": "3.946.0", "@aws-sdk/credential-provider-sso": "3.946.0", "@aws-sdk/credential-provider-web-identity": "3.946.0", "@aws-sdk/types": "3.936.0", "@smithy/credential-provider-imds": "^4.2.5", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-I7URUqnBPng1a5y81OImxrwERysZqMBREG6svhhGeZgxmqcpAZ8z5ywILeQXdEOCuuES8phUp/ojzxFjPXp/eA=="], 61 + 62 + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.946.0", "", { "dependencies": { "@aws-sdk/core": "3.946.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-GtGHX7OGqIeVQ3DlVm5RRF43Qmf3S1+PLJv9svrdvAhAdy2bUb044FdXXqrtSsIfpzTKlHgQUiRo5MWLd35Ntw=="], 63 + 64 + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.946.0", "", { "dependencies": { "@aws-sdk/client-sso": "3.946.0", "@aws-sdk/core": "3.946.0", "@aws-sdk/token-providers": "3.946.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-LeGSSt2V5iwYey1ENGY75RmoDP3bA2iE/py8QBKW8EDA8hn74XBLkprhrK5iccOvU3UGWY8WrEKFAFGNjJOL9g=="], 65 + 66 + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.946.0", "", { "dependencies": { "@aws-sdk/core": "3.946.0", "@aws-sdk/nested-clients": "3.946.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-ocBCvjWfkbjxElBI1QUxOnHldsNhoU0uOICFvuRDAZAoxvypJHN3m5BJkqb7gqorBbcv3LRgmBdEnWXOAvq+7Q=="], 67 + 68 + "@aws-sdk/lib-storage": ["@aws-sdk/lib-storage@3.962.0", "", { "dependencies": { "@smithy/abort-controller": "^4.2.7", "@smithy/middleware-endpoint": "^4.4.1", "@smithy/smithy-client": "^4.10.2", "buffer": "5.6.0", "events": "3.3.0", "stream-browserify": "3.0.0", "tslib": "^2.6.2" }, "peerDependencies": { "@aws-sdk/client-s3": "^3.962.0" } }, "sha512-Ai5gWRQkzsUMQ6NPoZZoiLXoQ6/yPRcR4oracIVjyWcu48TfBpsRgbqY/5zNOM55ag1wPX9TtJJGOhK3TNk45g=="], 69 + 70 + "@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@aws-sdk/util-arn-parser": "3.893.0", "@smithy/node-config-provider": "^4.3.5", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "@smithy/util-config-provider": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-XLSVVfAorUxZh6dzF+HTOp4R1B5EQcdpGcPliWr0KUj2jukgjZEcqbBmjyMF/p9bmyQsONX80iURF1HLAlW0qg=="], 71 + 72 + "@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-Eb4ELAC23bEQLJmUMYnPWcjD3FZIsmz2svDiXEcxRkQU9r7NRID7pM7C5NPH94wOfiCk0b2Y8rVyFXW0lGQwbA=="], 73 + 74 + "@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.946.0", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "3.946.0", "@aws-sdk/types": "3.936.0", "@smithy/is-array-buffer": "^4.2.0", "@smithy/node-config-provider": "^4.3.5", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-HJA7RIWsnxcChyZ1hNF/3JICkYCqDonxoeG8FkrmLRBknZ8WVdJiPD420/UwrWaa5F2MuTDA92jxk77rI09h1w=="], 75 + 76 + "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-tAaObaAnsP1XnLGndfkGWFuzrJYuk9W0b/nLvol66t8FZExIAf/WdkT2NNAWOYxljVs++oHnyHBCxIlaHrzSiw=="], 77 + 78 + "@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-SCMPenDtQMd9o5da9JzkHz838w3327iqXk3cbNnXWqnNRx6unyW8FL0DZ84gIY12kAyVHz5WEqlWuekc15ehfw=="], 79 + 80 + "@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-aPSJ12d3a3Ea5nyEnLbijCaaYJT2QjQ9iW+zGh5QcZYXmOGWbKVyPSxmVOboZQG+c1M8t6d2O7tqrwzIq8L8qw=="], 81 + 82 + "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@aws/lambda-invoke-store": "^0.2.0", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-l4aGbHpXM45YNgXggIux1HgsCVAvvBoqHPkqLnqMl9QVapfuSTjJHfDYDsx1Xxct6/m7qSMUzanBALhiaGO2fA=="], 83 + 84 + "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.946.0", "", { "dependencies": { "@aws-sdk/core": "3.946.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-arn-parser": "3.893.0", "@smithy/core": "^3.18.7", "@smithy/node-config-provider": "^4.3.5", "@smithy/protocol-http": "^5.3.5", "@smithy/signature-v4": "^5.3.5", "@smithy/smithy-client": "^4.9.10", "@smithy/types": "^4.9.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-0UTFmFd8PX2k/jLu/DBmR+mmLQWAtUGHYps9Rjx3dcXNwaMLaa/39NoV3qn7Dwzfpqc6JZlZzBk+NDOCJIHW9g=="], 85 + 86 + "@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-/GLC9lZdVp05ozRik5KsuODR/N7j+W+2TbfdFL3iS+7un+gnP6hC8RDOZd6WhpZp7drXQ9guKiTAxkZQwzS8DA=="], 87 + 88 + "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.946.0", "", { "dependencies": { "@aws-sdk/core": "3.946.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@smithy/core": "^3.18.7", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-7QcljCraeaWQNuqmOoAyZs8KpZcuhPiqdeeKoRd397jVGNRehLFsZbIMOvwaluUDFY11oMyXOkQEERe1Zo2fCw=="], 89 + 90 + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.946.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.946.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-user-agent": "3.946.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.946.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.7", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.14", "@smithy/middleware-retry": "^4.4.14", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.10", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.13", "@smithy/util-defaults-mode-node": "^4.2.16", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-rjAtEguukeW8mlyEQMQI56vxFoyWlaNwowmz1p1rav948SUjtrzjHAp4TOQWhibb7AR7BUTHBCgIcyCRjBEf4g=="], 91 + 92 + "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/config-resolver": "^4.4.3", "@smithy/node-config-provider": "^4.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-wOKhzzWsshXGduxO4pqSiNyL9oUtk4BEvjWm9aaq6Hmfdoydq6v6t0rAGHWPjFwy9z2haovGRi3C8IxdMB4muw=="], 93 + 94 + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.946.0", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "3.946.0", "@aws-sdk/types": "3.936.0", "@smithy/protocol-http": "^5.3.5", "@smithy/signature-v4": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-61FZ685lKiJuQ06g6U7K3PL9EwKCxNm51wNlxyKV57nnl1GrLD0NC8O3/hDNkCQLNBArT9y3IXl2H7TtIxP8Jg=="], 95 + 96 + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.946.0", "", { "dependencies": { "@aws-sdk/core": "3.946.0", "@aws-sdk/nested-clients": "3.946.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-a5c+rM6CUPX2ExmUZ3DlbLlS5rQr4tbdoGcgBsjnAHiYx8MuMNAI+8M7wfjF13i2yvUQj5WEIddvLpayfEZj9g=="], 97 + 98 + "@aws-sdk/types": ["@aws-sdk/types@3.936.0", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-uz0/VlMd2pP5MepdrHizd+T+OKfyK4r3OA9JI+L/lPKg0YFQosdJNCKisr6o70E3dh8iMpFYxF1UN/4uZsyARg=="], 99 + 100 + "@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.893.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-u8H4f2Zsi19DGnwj5FSZzDMhytYF/bCh37vAtBsn3cNDL3YG578X5oc+wSX54pM3tOxS+NY7tvOAo52SW7koUA=="], 101 + 102 + "@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-endpoints": "^3.2.5", "tslib": "^2.6.2" } }, "sha512-0Zx3Ntdpu+z9Wlm7JKUBOzS9EunwKAb4KdGUQQxDqh5Lc3ta5uBoub+FgmVuzwnmBu9U1Os8UuwVTH0Lgu+P5w=="], 103 + 104 + "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.893.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-T89pFfgat6c8nMmpI8eKjBcDcgJq36+m9oiXbcUzeU55MP9ZuGgBomGjGnHaEyF36jenW9gmg3NfZDm0AO2XPg=="], 105 + 106 + "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/types": "^4.9.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-eZ/XF6NxMtu+iCma58GRNRxSq4lHo6zHQLOZRIeL/ghqYJirqHdenMOwrzPettj60KWlv827RVebP9oNVrwZbw=="], 107 + 108 + "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.946.0", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "3.946.0", "@aws-sdk/types": "3.936.0", "@smithy/node-config-provider": "^4.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-a2UwwvzbK5AxHKUBupfg4s7VnkqRAHjYsuezHnKCniczmT4HZfP1NnfwwvLKEH8qaTrwenxjKSfq4UWmWkvG+Q=="], 109 + 110 + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.930.0", "", { "dependencies": { "@smithy/types": "^4.9.0", "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" } }, "sha512-YIfkD17GocxdmlUVc3ia52QhcWuRIUJonbF8A2CYfcWNV3HzvAqpcPeC0bYUhkK+8e8YO1ARnLKZQE0TlwzorA=="], 111 + 112 + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.2", "", {}, "sha512-C0NBLsIqzDIae8HFw9YIrIBsbc0xTiOtt7fAukGPnqQ/+zZNaq+4jhuccltK0QuWHBnNm/a6kLIRA6GFiM10eg=="], 113 + 114 + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA=="], 115 + 116 + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.1", "", { "os": "android", "cpu": "arm" }, "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg=="], 117 + 118 + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.1", "", { "os": "android", "cpu": "arm64" }, "sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ=="], 119 + 120 + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.1", "", { "os": "android", "cpu": "x64" }, "sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ=="], 121 + 122 + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ=="], 123 + 124 + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ=="], 125 + 126 + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg=="], 127 + 128 + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ=="], 129 + 130 + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.1", "", { "os": "linux", "cpu": "arm" }, "sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA=="], 131 + 132 + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q=="], 133 + 134 + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw=="], 135 + 136 + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.1", "", { "os": "linux", "cpu": "none" }, "sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg=="], 137 + 138 + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.1", "", { "os": "linux", "cpu": "none" }, "sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA=="], 139 + 140 + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ=="], 141 + 142 + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.1", "", { "os": "linux", "cpu": "none" }, "sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ=="], 143 + 144 + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw=="], 145 + 146 + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.1", "", { "os": "linux", "cpu": "x64" }, "sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA=="], 147 + 148 + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.1", "", { "os": "none", "cpu": "arm64" }, "sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ=="], 149 + 150 + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.1", "", { "os": "none", "cpu": "x64" }, "sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg=="], 151 + 152 + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g=="], 153 + 154 + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg=="], 155 + 156 + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg=="], 157 + 158 + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA=="], 159 + 160 + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg=="], 161 + 162 + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ=="], 163 + 164 + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.1", "", { "os": "win32", "cpu": "x64" }, "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw=="], 165 + 166 + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="], 167 + 168 + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], 169 + 170 + "@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="], 171 + 172 + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], 173 + 174 + "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], 175 + 176 + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.3", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ=="], 177 + 178 + "@eslint/js": ["@eslint/js@9.39.2", "", {}, "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA=="], 179 + 180 + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], 181 + 182 + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], 183 + 184 + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], 185 + 186 + "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], 187 + 188 + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], 189 + 190 + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], 191 + 192 + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], 193 + 194 + "@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], 195 + 196 + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.53.3", "", { "os": "android", "cpu": "arm" }, "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w=="], 197 + 198 + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.53.3", "", { "os": "android", "cpu": "arm64" }, "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w=="], 199 + 200 + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.53.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA=="], 201 + 202 + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.53.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ=="], 203 + 204 + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.53.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w=="], 205 + 206 + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.53.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q=="], 207 + 208 + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.53.3", "", { "os": "linux", "cpu": "arm" }, "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw=="], 209 + 210 + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.53.3", "", { "os": "linux", "cpu": "arm" }, "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg=="], 211 + 212 + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.53.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w=="], 213 + 214 + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.53.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A=="], 215 + 216 + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g=="], 217 + 218 + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.53.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw=="], 219 + 220 + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g=="], 221 + 222 + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A=="], 223 + 224 + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.53.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg=="], 225 + 226 + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.53.3", "", { "os": "linux", "cpu": "x64" }, "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w=="], 227 + 228 + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.53.3", "", { "os": "linux", "cpu": "x64" }, "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q=="], 229 + 230 + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.53.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw=="], 231 + 232 + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.53.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw=="], 233 + 234 + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.53.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA=="], 235 + 236 + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.53.3", "", { "os": "win32", "cpu": "x64" }, "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg=="], 237 + 238 + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.53.3", "", { "os": "win32", "cpu": "x64" }, "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ=="], 239 + 240 + "@smithy/abort-controller": ["@smithy/abort-controller@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-rzMY6CaKx2qxrbYbqjXWS0plqEy7LOdKHS0bg4ixJ6aoGDPNUcLWk/FRNuCILh7GKLG9TFUXYYeQQldMBBwuyw=="], 241 + 242 + "@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA=="], 243 + 244 + "@smithy/chunked-blob-reader-native": ["@smithy/chunked-blob-reader-native@4.2.1", "", { "dependencies": { "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ=="], 245 + 246 + "@smithy/config-resolver": ["@smithy/config-resolver@4.4.3", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.5", "@smithy/types": "^4.9.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "tslib": "^2.6.2" } }, "sha512-ezHLe1tKLUxDJo2LHtDuEDyWXolw8WGOR92qb4bQdWq/zKenO5BvctZGrVJBK08zjezSk7bmbKFOXIVyChvDLw=="], 247 + 248 + "@smithy/core": ["@smithy/core@3.18.7", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.6", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-axG9MvKhMWOhFbvf5y2DuyTxQueO0dkedY9QC3mAfndLosRI/9LJv8WaL0mw7ubNhsO4IuXX9/9dYGPFvHrqlw=="], 249 + 250 + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.5", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.5", "@smithy/property-provider": "^4.2.5", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "tslib": "^2.6.2" } }, "sha512-BZwotjoZWn9+36nimwm/OLIcVe+KYRwzMjfhd4QT7QxPm9WY0HiOV8t/Wlh+HVUif0SBVV7ksq8//hPaBC/okQ=="], 251 + 252 + "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.5", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.9.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-Ogt4Zi9hEbIP17oQMd68qYOHUzmH47UkK7q7Gl55iIm9oKt27MUGrC5JfpMroeHjdkOliOA4Qt3NQ1xMq/nrlA=="], 253 + 254 + "@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.5", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-HohfmCQZjppVnKX2PnXlf47CW3j92Ki6T/vkAT2DhBR47e89pen3s4fIa7otGTtrVxmj7q+IhH0RnC5kpR8wtw=="], 255 + 256 + "@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-ibjQjM7wEXtECiT6my1xfiMH9IcEczMOS6xiCQXoUIYSj5b1CpBbJ3VYbdwDy8Vcg5JHN7eFpOCGk8nyZAltNQ=="], 257 + 258 + "@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.5", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-+elOuaYx6F2H6x1/5BQP5ugv12nfJl66GhxON8+dWVUEDJ9jah/A0tayVdkLRP0AeSac0inYkDz5qBFKfVp2Gg=="], 259 + 260 + "@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.5", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-G9WSqbST45bmIFaeNuP/EnC19Rhp54CcVdX9PDL1zyEB514WsDVXhlyihKlGXnRycmHNmVv88Bvvt4EYxWef/Q=="], 261 + 262 + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.6", "", { "dependencies": { "@smithy/protocol-http": "^5.3.5", "@smithy/querystring-builder": "^4.2.5", "@smithy/types": "^4.9.0", "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-3+RG3EA6BBJ/ofZUeTFJA7mHfSYrZtQIrDP9dI8Lf7X6Jbos2jptuLrAAteDiFVrmbEmLSuRG/bUKzfAXk7dhg=="], 263 + 264 + "@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.6", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.0", "@smithy/chunked-blob-reader-native": "^4.2.1", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-8P//tA8DVPk+3XURk2rwcKgYwFvwGwmJH/wJqQiSKwXZtf/LiZK+hbUZmPj/9KzM+OVSwe4o85KTp5x9DUZTjw=="], 265 + 266 + "@smithy/hash-node": ["@smithy/hash-node@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DpYX914YOfA3UDT9CN1BM787PcHfWRBB43fFGCYrZFUH0Jv+5t8yYl+Pd5PW4+QzoGEDvn5d5QIO4j2HyYZQSA=="], 267 + 268 + "@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-6+do24VnEyvWcGdHXomlpd0m8bfZePpUKBy7m311n+JuRwug8J4dCanJdTymx//8mi0nlkflZBvJe+dEO/O12Q=="], 269 + 270 + "@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-2L2erASEro1WC5nV+plwIMxrTXpvpfzl4e+Nre6vBVRR2HKeGGcvpJyyL3/PpiSg+cJG2KpTmZmq934Olb6e5A=="], 271 + 272 + "@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ=="], 273 + 274 + "@smithy/md5-js": ["@smithy/md5-js@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-Bt6jpSTMWfjCtC0s79gZ/WZ1w90grfmopVOWqkI2ovhjpD5Q2XRXuecIPB9689L2+cCySMbaXDhBPU56FKNDNg=="], 275 + 276 + "@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.5", "", { "dependencies": { "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-Y/RabVa5vbl5FuHYV2vUCwvh/dqzrEY/K2yWPSqvhFUwIY0atLqO4TienjBXakoy4zrKAMCZwg+YEqmH7jaN7A=="], 277 + 278 + "@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.3.14", "", { "dependencies": { "@smithy/core": "^3.18.7", "@smithy/middleware-serde": "^4.2.6", "@smithy/node-config-provider": "^4.3.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-middleware": "^4.2.5", "tslib": "^2.6.2" } }, "sha512-v0q4uTKgBM8dsqGjqsabZQyH85nFaTnFcgpWU1uydKFsdyyMzfvOkNum9G7VK+dOP01vUnoZxIeRiJ6uD0kjIg=="], 279 + 280 + "@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.14", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.5", "@smithy/protocol-http": "^5.3.5", "@smithy/service-error-classification": "^4.2.5", "@smithy/smithy-client": "^4.9.10", "@smithy/types": "^4.9.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-Z2DG8Ej7FyWG1UA+7HceINtSLzswUgs2np3sZX0YBBxCt+CXG4QUxv88ZDS3+2/1ldW7LqtSY1UO/6VQ1pND8Q=="], 281 + 282 + "@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.6", "", { "dependencies": { "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-VkLoE/z7e2g8pirwisLz8XJWedUSY8my/qrp81VmAdyrhi94T+riBfwP+AOEEFR9rFTSonC/5D2eWNmFabHyGQ=="], 283 + 284 + "@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-bYrutc+neOyWxtZdbB2USbQttZN0mXaOyYLIsaTbJhFsfpXyGWUxJpEuO1rJ8IIJm2qH4+xJT0mxUSsEDTYwdQ=="], 285 + 286 + "@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.5", "", { "dependencies": { "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-UTurh1C4qkVCtqggI36DGbLB2Kv8UlcFdMXDcWMbqVY2uRg0XmT9Pb4Vj6oSQ34eizO1fvR0RnFV4Axw4IrrAg=="], 287 + 288 + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.5", "", { "dependencies": { "@smithy/abort-controller": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/querystring-builder": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-CMnzM9R2WqlqXQGtIlsHMEZfXKJVTIrqCNoSd/QpAyp+Dw0a1Vps13l6ma1fH8g7zSPNsA59B/kWgeylFuA/lw=="], 289 + 290 + "@smithy/property-provider": ["@smithy/property-provider@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-8iLN1XSE1rl4MuxvQ+5OSk/Zb5El7NJZ1td6Tn+8dQQHIjp59Lwl6bd0+nzw6SKm2wSSriH2v/I9LPzUic7EOg=="], 291 + 292 + "@smithy/protocol-http": ["@smithy/protocol-http@5.3.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-RlaL+sA0LNMp03bf7XPbFmT5gN+w3besXSWMkA8rcmxLSVfiEXElQi4O2IWwPfxzcHkxqrwBFMbngB8yx/RvaQ=="], 293 + 294 + "@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "@smithy/util-uri-escape": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-y98otMI1saoajeik2kLfGyRp11e5U/iJYH/wLCh3aTV/XutbGT9nziKGkgCaMD1ghK7p6htHMm6b6scl9JRUWg=="], 295 + 296 + "@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-031WCTdPYgiQRYNPXznHXof2YM0GwL6SeaSyTH/P72M1Vz73TvCNH2Nq8Iu2IEPq9QP2yx0/nrw5YmSeAi/AjQ=="], 297 + 298 + "@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0" } }, "sha512-8fEvK+WPE3wUAcDvqDQG1Vk3ANLR8Px979te96m84CbKAjBVf25rPYSzb4xU4hlTyho7VhOGnh5i62D/JVF0JQ=="], 299 + 300 + "@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.0", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-5WmZ5+kJgJDjwXXIzr1vDTG+RhF9wzSODQBfkrQ2VVkYALKGvZX1lgVSxEkgicSAFnFhPj5rudJV0zoinqS0bA=="], 301 + 302 + "@smithy/signature-v4": ["@smithy/signature-v4@5.3.5", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-uri-escape": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-xSUfMu1FT7ccfSXkoLl/QRQBi2rOvi3tiBZU2Tdy3I6cgvZ6SEi9QNey+lqps/sJRnogIS+lq+B1gxxbra2a/w=="], 303 + 304 + "@smithy/smithy-client": ["@smithy/smithy-client@4.9.10", "", { "dependencies": { "@smithy/core": "^3.18.7", "@smithy/middleware-endpoint": "^4.3.14", "@smithy/middleware-stack": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "@smithy/util-stream": "^4.5.6", "tslib": "^2.6.2" } }, "sha512-Jaoz4Jw1QYHc1EFww/E6gVtNjhoDU+gwRKqXP6C3LKYqqH2UQhP8tMP3+t/ePrhaze7fhLE8vS2q6vVxBANFTQ=="], 305 + 306 + "@smithy/types": ["@smithy/types@4.9.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-MvUbdnXDTwykR8cB1WZvNNwqoWVaTRA0RLlLmf/cIFNMM2cKWz01X4Ly6SMC4Kks30r8tT3Cty0jmeWfiuyHTA=="], 307 + 308 + "@smithy/url-parser": ["@smithy/url-parser@4.2.5", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-VaxMGsilqFnK1CeBX+LXnSuaMx4sTL/6znSZh2829txWieazdVxr54HmiyTsIbpOTLcf5nYpq9lpzmwRdxj6rQ=="], 309 + 310 + "@smithy/util-base64": ["@smithy/util-base64@4.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ=="], 311 + 312 + "@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg=="], 313 + 314 + "@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.2.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA=="], 315 + 316 + "@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew=="], 317 + 318 + "@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q=="], 319 + 320 + "@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.13", "", { "dependencies": { "@smithy/property-provider": "^4.2.5", "@smithy/smithy-client": "^4.9.10", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-hlVLdAGrVfyNei+pKIgqDTxfu/ZI2NSyqj4IDxKd5bIsIqwR/dSlkxlPaYxFiIaDVrBy0he8orsFy+Cz119XvA=="], 321 + 322 + "@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.16", "", { "dependencies": { "@smithy/config-resolver": "^4.4.3", "@smithy/credential-provider-imds": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/property-provider": "^4.2.5", "@smithy/smithy-client": "^4.9.10", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-F1t22IUiJLHrxW9W1CQ6B9PN+skZ9cqSuzB18Eh06HrJPbjsyZ7ZHecAKw80DQtyGTRcVfeukKaCRYebFwclbg=="], 323 + 324 + "@smithy/util-endpoints": ["@smithy/util-endpoints@3.2.5", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-3O63AAWu2cSNQZp+ayl9I3NapW1p1rR5mlVHcF6hAB1dPZUQFfRPYtplWX/3xrzWthPGj5FqB12taJJCfH6s8A=="], 325 + 326 + "@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw=="], 327 + 328 + "@smithy/util-middleware": ["@smithy/util-middleware@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-6Y3+rvBF7+PZOc40ybeZMcGln6xJGVeY60E7jy9Mv5iKpMJpHgRE6dKy9ScsVxvfAYuEX4Q9a65DQX90KaQ3bA=="], 329 + 330 + "@smithy/util-retry": ["@smithy/util-retry@4.2.5", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-GBj3+EZBbN4NAqJ/7pAhsXdfzdlznOh8PydUijy6FpNIMnHPSMO2/rP4HKu+UFeikJxShERk528oy7GT79YiJg=="], 331 + 332 + "@smithy/util-stream": ["@smithy/util-stream@4.5.6", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.6", "@smithy/node-http-handler": "^4.4.5", "@smithy/types": "^4.9.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-qWw/UM59TiaFrPevefOZ8CNBKbYEP6wBAIlLqxn3VAIo9rgnTNc4ASbVrqDmhuwI87usnjhdQrxodzAGFFzbRQ=="], 333 + 334 + "@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA=="], 335 + 336 + "@smithy/util-utf8": ["@smithy/util-utf8@4.2.0", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw=="], 337 + 338 + "@smithy/util-waiter": ["@smithy/util-waiter@4.2.5", "", { "dependencies": { "@smithy/abort-controller": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-Dbun99A3InifQdIrsXZ+QLcC0PGBPAdrl4cj1mTgJvyc9N2zf7QSxg8TBkzsCmGJdE3TLbO9ycwpY0EkWahQ/g=="], 339 + 340 + "@smithy/uuid": ["@smithy/uuid@1.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw=="], 341 + 342 + "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], 343 + 344 + "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], 345 + 346 + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], 347 + 348 + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], 349 + 350 + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], 351 + 352 + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], 353 + 354 + "@types/mime-types": ["@types/mime-types@3.0.1", "", {}, "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ=="], 355 + 356 + "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], 357 + 358 + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.49.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/type-utils": "8.49.0", "@typescript-eslint/utils": "8.49.0", "@typescript-eslint/visitor-keys": "8.49.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.49.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A=="], 359 + 360 + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.49.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", "@typescript-eslint/typescript-estree": "8.49.0", "@typescript-eslint/visitor-keys": "8.49.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA=="], 361 + 362 + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.49.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.49.0", "@typescript-eslint/types": "^8.49.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g=="], 363 + 364 + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.49.0", "", { "dependencies": { "@typescript-eslint/types": "8.49.0", "@typescript-eslint/visitor-keys": "8.49.0" } }, "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg=="], 365 + 366 + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.49.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA=="], 367 + 368 + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.49.0", "", { "dependencies": { "@typescript-eslint/types": "8.49.0", "@typescript-eslint/typescript-estree": "8.49.0", "@typescript-eslint/utils": "8.49.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg=="], 369 + 370 + "@typescript-eslint/types": ["@typescript-eslint/types@8.49.0", "", {}, "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ=="], 371 + 372 + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.49.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.49.0", "@typescript-eslint/tsconfig-utils": "8.49.0", "@typescript-eslint/types": "8.49.0", "@typescript-eslint/visitor-keys": "8.49.0", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA=="], 373 + 374 + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.49.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", "@typescript-eslint/typescript-estree": "8.49.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA=="], 375 + 376 + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.49.0", "", { "dependencies": { "@typescript-eslint/types": "8.49.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA=="], 377 + 378 + "@vitest/expect": ["@vitest/expect@4.0.15", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.15", "@vitest/utils": "4.0.15", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-Gfyva9/GxPAWXIWjyGDli9O+waHDC0Q0jaLdFP1qPAUUfo1FEXPXUfUkp3eZA0sSq340vPycSyOlYUeM15Ft1w=="], 379 + 380 + "@vitest/mocker": ["@vitest/mocker@4.0.15", "", { "dependencies": { "@vitest/spy": "4.0.15", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-CZ28GLfOEIFkvCFngN8Sfx5h+Se0zN+h4B7yOsPVCcgtiO7t5jt9xQh2E1UkFep+eb9fjyMfuC5gBypwb07fvQ=="], 381 + 382 + "@vitest/pretty-format": ["@vitest/pretty-format@4.0.15", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-SWdqR8vEv83WtZcrfLNqlqeQXlQLh2iilO1Wk1gv4eiHKjEzvgHb2OVc3mIPyhZE6F+CtfYjNlDJwP5MN6Km7A=="], 383 + 384 + "@vitest/runner": ["@vitest/runner@4.0.15", "", { "dependencies": { "@vitest/utils": "4.0.15", "pathe": "^2.0.3" } }, "sha512-+A+yMY8dGixUhHmNdPUxOh0la6uVzun86vAbuMT3hIDxMrAOmn5ILBHm8ajrqHE0t8R9T1dGnde1A5DTnmi3qw=="], 385 + 386 + "@vitest/snapshot": ["@vitest/snapshot@4.0.15", "", { "dependencies": { "@vitest/pretty-format": "4.0.15", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-A7Ob8EdFZJIBjLjeO0DZF4lqR6U7Ydi5/5LIZ0xcI+23lYlsYJAfGn8PrIWTYdZQRNnSRlzhg0zyGu37mVdy5g=="], 387 + 388 + "@vitest/spy": ["@vitest/spy@4.0.15", "", {}, "sha512-+EIjOJmnY6mIfdXtE/bnozKEvTC4Uczg19yeZ2vtCz5Yyb0QQ31QWVQ8hswJ3Ysx/K2EqaNsVanjr//2+P3FHw=="], 389 + 390 + "@vitest/utils": ["@vitest/utils@4.0.15", "", { "dependencies": { "@vitest/pretty-format": "4.0.15", "tinyrainbow": "^3.0.3" } }, "sha512-HXjPW2w5dxhTD0dLwtYHDnelK3j8sR8cWIaLxr22evTyY6q8pRCjZSmhRWVjBaOVXChQd6AwMzi9pucorXCPZA=="], 391 + 392 + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], 393 + 394 + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], 395 + 396 + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], 397 + 398 + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], 399 + 400 + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], 401 + 402 + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], 403 + 404 + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], 405 + 406 + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], 407 + 408 + "bowser": ["bowser@2.13.1", "", {}, "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw=="], 409 + 410 + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], 411 + 412 + "buffer": ["buffer@5.6.0", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4" } }, "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw=="], 413 + 414 + "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], 415 + 416 + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], 417 + 418 + "chai": ["chai@6.2.1", "", {}, "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg=="], 419 + 420 + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], 421 + 422 + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], 423 + 424 + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], 425 + 426 + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], 427 + 428 + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], 429 + 430 + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], 431 + 432 + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], 433 + 434 + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], 435 + 436 + "esbuild": ["esbuild@0.27.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.1", "@esbuild/android-arm": "0.27.1", "@esbuild/android-arm64": "0.27.1", "@esbuild/android-x64": "0.27.1", "@esbuild/darwin-arm64": "0.27.1", "@esbuild/darwin-x64": "0.27.1", "@esbuild/freebsd-arm64": "0.27.1", "@esbuild/freebsd-x64": "0.27.1", "@esbuild/linux-arm": "0.27.1", "@esbuild/linux-arm64": "0.27.1", "@esbuild/linux-ia32": "0.27.1", "@esbuild/linux-loong64": "0.27.1", "@esbuild/linux-mips64el": "0.27.1", "@esbuild/linux-ppc64": "0.27.1", "@esbuild/linux-riscv64": "0.27.1", "@esbuild/linux-s390x": "0.27.1", "@esbuild/linux-x64": "0.27.1", "@esbuild/netbsd-arm64": "0.27.1", "@esbuild/netbsd-x64": "0.27.1", "@esbuild/openbsd-arm64": "0.27.1", "@esbuild/openbsd-x64": "0.27.1", "@esbuild/openharmony-arm64": "0.27.1", "@esbuild/sunos-x64": "0.27.1", "@esbuild/win32-arm64": "0.27.1", "@esbuild/win32-ia32": "0.27.1", "@esbuild/win32-x64": "0.27.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA=="], 437 + 438 + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], 439 + 440 + "eslint": ["eslint@9.39.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw=="], 441 + 442 + "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], 443 + 444 + "eslint-plugin-prettier": ["eslint-plugin-prettier@5.5.4", "", { "dependencies": { "prettier-linter-helpers": "^1.0.0", "synckit": "^0.11.7" }, "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "optionalPeers": ["@types/eslint", "eslint-config-prettier"] }, "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg=="], 445 + 446 + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], 447 + 448 + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], 449 + 450 + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], 451 + 452 + "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], 453 + 454 + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], 455 + 456 + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], 457 + 458 + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], 459 + 460 + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], 461 + 462 + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], 463 + 464 + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], 465 + 466 + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], 467 + 468 + "fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="], 469 + 470 + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], 471 + 472 + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], 473 + 474 + "fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="], 475 + 476 + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], 477 + 478 + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], 479 + 480 + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], 481 + 482 + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], 483 + 484 + "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], 485 + 486 + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 487 + 488 + "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], 489 + 490 + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], 491 + 492 + "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], 493 + 494 + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], 495 + 496 + "hono": ["hono@4.10.7", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="], 497 + 498 + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], 499 + 500 + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], 501 + 502 + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], 503 + 504 + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], 505 + 506 + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], 507 + 508 + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], 509 + 510 + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], 511 + 512 + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], 513 + 514 + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], 515 + 516 + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], 517 + 518 + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], 519 + 520 + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], 521 + 522 + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], 523 + 524 + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], 525 + 526 + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], 527 + 528 + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], 529 + 530 + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], 531 + 532 + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], 533 + 534 + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], 535 + 536 + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], 537 + 538 + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 539 + 540 + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], 541 + 542 + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], 543 + 544 + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], 545 + 546 + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], 547 + 548 + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], 549 + 550 + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], 551 + 552 + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], 553 + 554 + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], 555 + 556 + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], 557 + 558 + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], 559 + 560 + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], 561 + 562 + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], 563 + 564 + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], 565 + 566 + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], 567 + 568 + "prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="], 569 + 570 + "prettier-linter-helpers": ["prettier-linter-helpers@1.0.0", "", { "dependencies": { "fast-diff": "^1.1.2" } }, "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w=="], 571 + 572 + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], 573 + 574 + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], 575 + 576 + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], 577 + 578 + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], 579 + 580 + "rollup": ["rollup@4.53.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.53.3", "@rollup/rollup-android-arm64": "4.53.3", "@rollup/rollup-darwin-arm64": "4.53.3", "@rollup/rollup-darwin-x64": "4.53.3", "@rollup/rollup-freebsd-arm64": "4.53.3", "@rollup/rollup-freebsd-x64": "4.53.3", "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", "@rollup/rollup-linux-arm-musleabihf": "4.53.3", "@rollup/rollup-linux-arm64-gnu": "4.53.3", "@rollup/rollup-linux-arm64-musl": "4.53.3", "@rollup/rollup-linux-loong64-gnu": "4.53.3", "@rollup/rollup-linux-ppc64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-musl": "4.53.3", "@rollup/rollup-linux-s390x-gnu": "4.53.3", "@rollup/rollup-linux-x64-gnu": "4.53.3", "@rollup/rollup-linux-x64-musl": "4.53.3", "@rollup/rollup-openharmony-arm64": "4.53.3", "@rollup/rollup-win32-arm64-msvc": "4.53.3", "@rollup/rollup-win32-ia32-msvc": "4.53.3", "@rollup/rollup-win32-x64-gnu": "4.53.3", "@rollup/rollup-win32-x64-msvc": "4.53.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA=="], 581 + 582 + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], 583 + 584 + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], 585 + 586 + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], 587 + 588 + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], 589 + 590 + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], 591 + 592 + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], 593 + 594 + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], 595 + 596 + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], 597 + 598 + "stream-browserify": ["stream-browserify@3.0.0", "", { "dependencies": { "inherits": "~2.0.4", "readable-stream": "^3.5.0" } }, "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA=="], 599 + 600 + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], 601 + 602 + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], 603 + 604 + "strnum": ["strnum@2.1.1", "", {}, "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw=="], 605 + 606 + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], 607 + 608 + "synckit": ["synckit@0.11.11", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw=="], 609 + 610 + "tiny-lru": ["tiny-lru@11.4.5", "", {}, "sha512-hkcz3FjNJfKXjV4mjQ1OrXSLAehg8Hw+cEZclOVT+5c/cWQWImQ9wolzTjth+dmmDe++p3bme3fTxz6Q4Etsqw=="], 611 + 612 + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], 613 + 614 + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], 615 + 616 + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], 617 + 618 + "tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], 619 + 620 + "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], 621 + 622 + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 623 + 624 + "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], 625 + 626 + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], 627 + 628 + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 629 + 630 + "typescript-eslint": ["typescript-eslint@8.50.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.50.0", "@typescript-eslint/parser": "8.50.0", "@typescript-eslint/typescript-estree": "8.50.0", "@typescript-eslint/utils": "8.50.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Q1/6yNUmCpH94fbgMUMg2/BSAr/6U7GBk61kZTv1/asghQOWOjTlp9K8mixS5NcJmm2creY+UFfGeW/+OcA64A=="], 631 + 632 + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 633 + 634 + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], 635 + 636 + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], 637 + 638 + "vite": ["vite@7.2.7", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ=="], 639 + 640 + "vitest": ["vitest@4.0.15", "", { "dependencies": { "@vitest/expect": "4.0.15", "@vitest/mocker": "4.0.15", "@vitest/pretty-format": "4.0.15", "@vitest/runner": "4.0.15", "@vitest/snapshot": "4.0.15", "@vitest/spy": "4.0.15", "@vitest/utils": "4.0.15", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.15", "@vitest/browser-preview": "4.0.15", "@vitest/browser-webdriverio": "4.0.15", "@vitest/ui": "4.0.15", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA=="], 641 + 642 + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], 643 + 644 + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], 645 + 646 + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], 647 + 648 + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], 649 + 650 + "@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], 651 + 652 + "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], 653 + 654 + "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], 655 + 656 + "@aws-sdk/lib-storage/@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.1", "", { "dependencies": { "@smithy/core": "^3.20.0", "@smithy/middleware-serde": "^4.2.8", "@smithy/node-config-provider": "^4.3.7", "@smithy/shared-ini-file-loader": "^4.4.2", "@smithy/types": "^4.11.0", "@smithy/url-parser": "^4.2.7", "@smithy/util-middleware": "^4.2.7", "tslib": "^2.6.2" } }, "sha512-gpLspUAoe6f1M6H0u4cVuFzxZBrsGZmjx2O9SigurTx4PbntYa4AJ+o0G0oGm1L2oSX6oBhcGHwrfJHup2JnJg=="], 657 + 658 + "@aws-sdk/lib-storage/@smithy/smithy-client": ["@smithy/smithy-client@4.10.2", "", { "dependencies": { "@smithy/core": "^3.20.0", "@smithy/middleware-endpoint": "^4.4.1", "@smithy/middleware-stack": "^4.2.7", "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "@smithy/util-stream": "^4.5.8", "tslib": "^2.6.2" } }, "sha512-D5z79xQWpgrGpAHb054Fn2CCTQZpog7JELbVQ6XAvXs5MNKWf28U9gzSBlJkOyMl9LA1TZEjRtwvGXfP0Sl90g=="], 659 + 660 + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], 661 + 662 + "@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], 663 + 664 + "@smithy/abort-controller/@smithy/types": ["@smithy/types@4.11.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-mlrmL0DRDVe3mNrjTcVcZEgkFmufITfUAPBEA+AHYiIeYyJebso/He1qLbP3PssRe22KUzLRpQSdBPbXdgZ2VA=="], 665 + 666 + "@smithy/node-http-handler/@smithy/abort-controller": ["@smithy/abort-controller@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-j7HwVkBw68YW8UmFRcjZOmssE77Rvk0GWAIN1oFBhsaovQmZWYCIcGa9/pwRB0ExI8Sk9MWNALTjftjHZea7VA=="], 667 + 668 + "@smithy/util-waiter/@smithy/abort-controller": ["@smithy/abort-controller@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-j7HwVkBw68YW8UmFRcjZOmssE77Rvk0GWAIN1oFBhsaovQmZWYCIcGa9/pwRB0ExI8Sk9MWNALTjftjHZea7VA=="], 669 + 670 + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], 671 + 672 + "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], 673 + 674 + "typescript-eslint/@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.50.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/type-utils": "8.50.0", "@typescript-eslint/utils": "8.50.0", "@typescript-eslint/visitor-keys": "8.50.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.50.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-O7QnmOXYKVtPrfYzMolrCTfkezCJS9+ljLdKW/+DCvRsc3UAz+sbH6Xcsv7p30+0OwUbeWfUDAQE0vpabZ3QLg=="], 675 + 676 + "typescript-eslint/@typescript-eslint/parser": ["@typescript-eslint/parser@8.50.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", "@typescript-eslint/typescript-estree": "8.50.0", "@typescript-eslint/visitor-keys": "8.50.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q=="], 677 + 678 + "typescript-eslint/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.50.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.50.0", "@typescript-eslint/tsconfig-utils": "8.50.0", "@typescript-eslint/types": "8.50.0", "@typescript-eslint/visitor-keys": "8.50.0", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-W7SVAGBR/IX7zm1t70Yujpbk+zdPq/u4soeFSknWFdXIFuWsBGBOUu/Tn/I6KHSKvSh91OiMuaSnYp3mtPt5IQ=="], 679 + 680 + "typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.50.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", "@typescript-eslint/typescript-estree": "8.50.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg=="], 681 + 682 + "vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], 683 + 684 + "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], 685 + 686 + "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], 687 + 688 + "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], 689 + 690 + "@aws-sdk/lib-storage/@smithy/middleware-endpoint/@smithy/core": ["@smithy/core@3.20.0", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.8", "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.7", "@smithy/util-stream": "^4.5.8", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-WsSHCPq/neD5G/MkK4csLI5Y5Pkd9c1NMfpYEKeghSGaD4Ja1qLIohRQf2D5c1Uy5aXp76DeKHkzWZ9KAlHroQ=="], 691 + 692 + "@aws-sdk/lib-storage/@smithy/middleware-endpoint/@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.8", "", { "dependencies": { "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-8rDGYen5m5+NV9eHv9ry0sqm2gI6W7mc1VSFMtn6Igo25S507/HaOX9LTHAS2/J32VXD0xSzrY0H5FJtOMS4/w=="], 693 + 694 + "@aws-sdk/lib-storage/@smithy/middleware-endpoint/@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.7", "", { "dependencies": { "@smithy/property-provider": "^4.2.7", "@smithy/shared-ini-file-loader": "^4.4.2", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-7r58wq8sdOcrwWe+klL9y3bc4GW1gnlfnFOuL7CXa7UzfhzhxKuzNdtqgzmTV+53lEp9NXh5hY/S4UgjLOzPfw=="], 695 + 696 + "@aws-sdk/lib-storage/@smithy/middleware-endpoint/@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.2", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-M7iUUff/KwfNunmrgtqBfvZSzh3bmFgv/j/t1Y1dQ+8dNo34br1cqVEqy6v0mYEgi0DkGO7Xig0AnuOaEGVlcg=="], 697 + 698 + "@aws-sdk/lib-storage/@smithy/middleware-endpoint/@smithy/types": ["@smithy/types@4.11.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-mlrmL0DRDVe3mNrjTcVcZEgkFmufITfUAPBEA+AHYiIeYyJebso/He1qLbP3PssRe22KUzLRpQSdBPbXdgZ2VA=="], 699 + 700 + "@aws-sdk/lib-storage/@smithy/middleware-endpoint/@smithy/url-parser": ["@smithy/url-parser@4.2.7", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-/RLtVsRV4uY3qPWhBDsjwahAtt3x2IsMGnP5W1b2VZIe+qgCqkLxI1UOHDZp1Q1QSOrdOR32MF3Ph2JfWT1VHg=="], 701 + 702 + "@aws-sdk/lib-storage/@smithy/middleware-endpoint/@smithy/util-middleware": ["@smithy/util-middleware@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-i1IkpbOae6NvIKsEeLLM9/2q4X+M90KV3oCFgWQI4q0Qz+yUZvsr+gZPdAEAtFhWQhAHpTsJO8DRJPuwVyln+w=="], 703 + 704 + "@aws-sdk/lib-storage/@smithy/smithy-client/@smithy/core": ["@smithy/core@3.20.0", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.8", "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.7", "@smithy/util-stream": "^4.5.8", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-WsSHCPq/neD5G/MkK4csLI5Y5Pkd9c1NMfpYEKeghSGaD4Ja1qLIohRQf2D5c1Uy5aXp76DeKHkzWZ9KAlHroQ=="], 705 + 706 + "@aws-sdk/lib-storage/@smithy/smithy-client/@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-bsOT0rJ+HHlZd9crHoS37mt8qRRN/h9jRve1SXUhVbkRzu0QaNYZp1i1jha4n098tsvROjcwfLlfvcFuJSXEsw=="], 707 + 708 + "@aws-sdk/lib-storage/@smithy/smithy-client/@smithy/protocol-http": ["@smithy/protocol-http@5.3.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-1r07pb994I20dD/c2seaZhoCuNYm0rWrvBxhCQ70brNh11M5Ml2ew6qJVo0lclB3jMIXirD4s2XRXRe7QEi0xA=="], 709 + 710 + "@aws-sdk/lib-storage/@smithy/smithy-client/@smithy/types": ["@smithy/types@4.11.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-mlrmL0DRDVe3mNrjTcVcZEgkFmufITfUAPBEA+AHYiIeYyJebso/He1qLbP3PssRe22KUzLRpQSdBPbXdgZ2VA=="], 711 + 712 + "@aws-sdk/lib-storage/@smithy/smithy-client/@smithy/util-stream": ["@smithy/util-stream@4.5.8", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.8", "@smithy/node-http-handler": "^4.4.7", "@smithy/types": "^4.11.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-ZnnBhTapjM0YPGUSmOs0Mcg/Gg87k503qG4zU2v/+Js2Gu+daKOJMeqcQns8ajepY8tgzzfYxl6kQyZKml6O2w=="], 713 + 714 + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], 715 + 716 + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.50.0", "", { "dependencies": { "@typescript-eslint/types": "8.50.0", "@typescript-eslint/visitor-keys": "8.50.0" } }, "sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A=="], 717 + 718 + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.50.0", "", { "dependencies": { "@typescript-eslint/types": "8.50.0", "@typescript-eslint/typescript-estree": "8.50.0", "@typescript-eslint/utils": "8.50.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-7OciHT2lKCewR0mFoBrvZJ4AXTMe/sYOe87289WAViOocEmDjjv8MvIOT2XESuKj9jp8u3SZYUSh89QA4S1kQw=="], 719 + 720 + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.50.0", "", { "dependencies": { "@typescript-eslint/types": "8.50.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q=="], 721 + 722 + "typescript-eslint/@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.50.0", "", { "dependencies": { "@typescript-eslint/types": "8.50.0", "@typescript-eslint/visitor-keys": "8.50.0" } }, "sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A=="], 723 + 724 + "typescript-eslint/@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.50.0", "", {}, "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w=="], 725 + 726 + "typescript-eslint/@typescript-eslint/parser/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.50.0", "", { "dependencies": { "@typescript-eslint/types": "8.50.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q=="], 727 + 728 + "typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.50.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.50.0", "@typescript-eslint/types": "^8.50.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Cg/nQcL1BcoTijEWyx4mkVC56r8dj44bFDvBdygifuS20f3OZCHmFbjF34DPSi07kwlFvqfv/xOLnJ5DquxSGQ=="], 729 + 730 + "typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.50.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-vxd3G/ybKTSlm31MOA96gqvrRGv9RJ7LGtZCn2Vrc5htA0zCDvcMqUkifcjrWNNKXHUU3WCkYOzzVSFBd0wa2w=="], 731 + 732 + "typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.50.0", "", {}, "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w=="], 733 + 734 + "typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.50.0", "", { "dependencies": { "@typescript-eslint/types": "8.50.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q=="], 735 + 736 + "typescript-eslint/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], 737 + 738 + "typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.50.0", "", { "dependencies": { "@typescript-eslint/types": "8.50.0", "@typescript-eslint/visitor-keys": "8.50.0" } }, "sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A=="], 739 + 740 + "typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.50.0", "", {}, "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w=="], 741 + 742 + "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], 743 + 744 + "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], 745 + 746 + "vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], 747 + 748 + "vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], 749 + 750 + "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], 751 + 752 + "vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], 753 + 754 + "vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], 755 + 756 + "vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], 757 + 758 + "vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], 759 + 760 + "vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], 761 + 762 + "vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], 763 + 764 + "vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], 765 + 766 + "vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], 767 + 768 + "vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], 769 + 770 + "vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], 771 + 772 + "vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], 773 + 774 + "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], 775 + 776 + "vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], 777 + 778 + "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], 779 + 780 + "vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], 781 + 782 + "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], 783 + 784 + "vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], 785 + 786 + "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], 787 + 788 + "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], 789 + 790 + "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], 791 + 792 + "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], 793 + 794 + "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], 795 + 796 + "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], 797 + 798 + "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], 799 + 800 + "@aws-sdk/lib-storage/@smithy/middleware-endpoint/@smithy/core/@smithy/protocol-http": ["@smithy/protocol-http@5.3.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-1r07pb994I20dD/c2seaZhoCuNYm0rWrvBxhCQ70brNh11M5Ml2ew6qJVo0lclB3jMIXirD4s2XRXRe7QEi0xA=="], 801 + 802 + "@aws-sdk/lib-storage/@smithy/middleware-endpoint/@smithy/core/@smithy/util-stream": ["@smithy/util-stream@4.5.8", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.8", "@smithy/node-http-handler": "^4.4.7", "@smithy/types": "^4.11.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-ZnnBhTapjM0YPGUSmOs0Mcg/Gg87k503qG4zU2v/+Js2Gu+daKOJMeqcQns8ajepY8tgzzfYxl6kQyZKml6O2w=="], 803 + 804 + "@aws-sdk/lib-storage/@smithy/middleware-endpoint/@smithy/middleware-serde/@smithy/protocol-http": ["@smithy/protocol-http@5.3.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-1r07pb994I20dD/c2seaZhoCuNYm0rWrvBxhCQ70brNh11M5Ml2ew6qJVo0lclB3jMIXirD4s2XRXRe7QEi0xA=="], 805 + 806 + "@aws-sdk/lib-storage/@smithy/middleware-endpoint/@smithy/node-config-provider/@smithy/property-provider": ["@smithy/property-provider@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-jmNYKe9MGGPoSl/D7JDDs1C8b3dC8f/w78LbaVfoTtWy4xAd5dfjaFG9c9PWPihY4ggMQNQSMtzU77CNgAJwmA=="], 807 + 808 + "@aws-sdk/lib-storage/@smithy/middleware-endpoint/@smithy/url-parser/@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-3X5ZvzUHmlSTHAXFlswrS6EGt8fMSIxX/c3Rm1Pni3+wYWB6cjGocmRIoqcQF9nU5OgGmL0u7l9m44tSUpfj9w=="], 809 + 810 + "@aws-sdk/lib-storage/@smithy/smithy-client/@smithy/core/@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.8", "", { "dependencies": { "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-8rDGYen5m5+NV9eHv9ry0sqm2gI6W7mc1VSFMtn6Igo25S507/HaOX9LTHAS2/J32VXD0xSzrY0H5FJtOMS4/w=="], 811 + 812 + "@aws-sdk/lib-storage/@smithy/smithy-client/@smithy/core/@smithy/util-middleware": ["@smithy/util-middleware@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-i1IkpbOae6NvIKsEeLLM9/2q4X+M90KV3oCFgWQI4q0Qz+yUZvsr+gZPdAEAtFhWQhAHpTsJO8DRJPuwVyln+w=="], 813 + 814 + "@aws-sdk/lib-storage/@smithy/smithy-client/@smithy/util-stream/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.8", "", { "dependencies": { "@smithy/protocol-http": "^5.3.7", "@smithy/querystring-builder": "^4.2.7", "@smithy/types": "^4.11.0", "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-h/Fi+o7mti4n8wx1SR6UHWLaakwHRx29sizvp8OOm7iqwKGFneT06GCSFhml6Bha5BT6ot5pj3CYZnCHhGC2Rg=="], 815 + 816 + "@aws-sdk/lib-storage/@smithy/smithy-client/@smithy/util-stream/@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.7", "", { "dependencies": { "@smithy/abort-controller": "^4.2.7", "@smithy/protocol-http": "^5.3.7", "@smithy/querystring-builder": "^4.2.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-NELpdmBOO6EpZtWgQiHjoShs1kmweaiNuETUpuup+cmm/xJYjT4eUjfhrXRP4jCOaAsS3c3yPsP3B+K+/fyPCQ=="], 817 + 818 + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.50.0", "", {}, "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w=="], 819 + 820 + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.50.0", "", {}, "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w=="], 821 + 822 + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.50.0", "", {}, "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w=="], 823 + 824 + "typescript-eslint/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], 825 + 826 + "typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.50.0", "", { "dependencies": { "@typescript-eslint/types": "8.50.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q=="], 827 + 828 + "@aws-sdk/lib-storage/@smithy/middleware-endpoint/@smithy/core/@smithy/util-stream/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.8", "", { "dependencies": { "@smithy/protocol-http": "^5.3.7", "@smithy/querystring-builder": "^4.2.7", "@smithy/types": "^4.11.0", "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-h/Fi+o7mti4n8wx1SR6UHWLaakwHRx29sizvp8OOm7iqwKGFneT06GCSFhml6Bha5BT6ot5pj3CYZnCHhGC2Rg=="], 829 + 830 + "@aws-sdk/lib-storage/@smithy/middleware-endpoint/@smithy/core/@smithy/util-stream/@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.7", "", { "dependencies": { "@smithy/abort-controller": "^4.2.7", "@smithy/protocol-http": "^5.3.7", "@smithy/querystring-builder": "^4.2.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-NELpdmBOO6EpZtWgQiHjoShs1kmweaiNuETUpuup+cmm/xJYjT4eUjfhrXRP4jCOaAsS3c3yPsP3B+K+/fyPCQ=="], 831 + 832 + "@aws-sdk/lib-storage/@smithy/smithy-client/@smithy/util-stream/@smithy/fetch-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "@smithy/util-uri-escape": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-eKONSywHZxK4tBxe2lXEysh8wbBdvDWiA+RIuaxZSgCMmA0zMgoDpGLJhnyj+c0leOQprVnXOmcB4m+W9Rw7sg=="], 833 + 834 + "@aws-sdk/lib-storage/@smithy/smithy-client/@smithy/util-stream/@smithy/node-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "@smithy/util-uri-escape": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-eKONSywHZxK4tBxe2lXEysh8wbBdvDWiA+RIuaxZSgCMmA0zMgoDpGLJhnyj+c0leOQprVnXOmcB4m+W9Rw7sg=="], 835 + 836 + "@aws-sdk/lib-storage/@smithy/middleware-endpoint/@smithy/core/@smithy/util-stream/@smithy/fetch-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "@smithy/util-uri-escape": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-eKONSywHZxK4tBxe2lXEysh8wbBdvDWiA+RIuaxZSgCMmA0zMgoDpGLJhnyj+c0leOQprVnXOmcB4m+W9Rw7sg=="], 837 + 838 + "@aws-sdk/lib-storage/@smithy/middleware-endpoint/@smithy/core/@smithy/util-stream/@smithy/node-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "@smithy/util-uri-escape": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-eKONSywHZxK4tBxe2lXEysh8wbBdvDWiA+RIuaxZSgCMmA0zMgoDpGLJhnyj+c0leOQprVnXOmcB4m+W9Rw7sg=="], 839 + } 840 + }
+33
packages/@wispplace/tiered-storage/eslint.config.js
··· 1 + import js from '@eslint/js'; 2 + import tseslint from 'typescript-eslint'; 3 + import prettier from 'eslint-plugin-prettier/recommended'; 4 + 5 + export default tseslint.config( 6 + js.configs.recommended, 7 + ...tseslint.configs.recommendedTypeChecked, 8 + prettier, 9 + { 10 + languageOptions: { 11 + parserOptions: { 12 + project: './tsconfig.eslint.json', 13 + tsconfigRootDir: import.meta.dirname, 14 + }, 15 + }, 16 + rules: { 17 + '@typescript-eslint/no-unused-vars': 'error', 18 + '@typescript-eslint/no-explicit-any': 'warn', 19 + '@typescript-eslint/prefer-nullish-coalescing': 'error', 20 + '@typescript-eslint/prefer-optional-chain': 'error', 21 + '@typescript-eslint/no-floating-promises': 'error', 22 + '@typescript-eslint/await-thenable': 'error', 23 + '@typescript-eslint/require-await': 'off', // Interface methods can be async for compatibility 24 + '@typescript-eslint/explicit-function-return-type': 'off', // Too noisy for test files 25 + 'prettier/prettier': 'error', 26 + 'indent': ['error', 'tab', { 'SwitchCase': 1 }], 27 + '@typescript-eslint/indent': 'off', // Prettier handles this 28 + }, 29 + }, 30 + { 31 + ignores: ['dist/', 'node_modules/', '*.js', '*.cjs', '*.mjs'], 32 + }, 33 + );
+47
packages/@wispplace/tiered-storage/example-site/README.md
··· 1 + # Example Static Site 2 + 3 + This is a demonstration static website used in the tiered-storage library examples. 4 + 5 + ## Files 6 + 7 + - **index.html** (3.5 KB) - Homepage, stored in hot + warm + cold tiers 8 + - **about.html** (4.2 KB) - About page, stored in warm + cold (skips hot) 9 + - **docs.html** (3.8 KB) - Documentation, stored in warm + cold (skips hot) 10 + - **style.css** (7.1 KB) - Stylesheet, stored in warm + cold (skips hot) 11 + - **script.js** (1.9 KB) - JavaScript, stored in warm + cold (skips hot) 12 + 13 + ## Usage in Examples 14 + 15 + The `example.ts` file demonstrates how this site would be stored using the tiered-storage library: 16 + 17 + 1. **index.html** is stored in all tiers (hot + warm + cold) because it's the entry point and needs instant serving 18 + 2. Other HTML pages skip the hot tier to save memory 19 + 3. CSS and JS files are stored in warm + cold 20 + 4. If there were large media files, they would be stored in cold tier only 21 + 22 + ## Tier Strategy 23 + 24 + ``` 25 + Hot Tier (Memory - 100MB): 26 + └── index.html (3.5 KB) 27 + 28 + Warm Tier (Disk - 10GB): 29 + ├── index.html (3.5 KB) 30 + ├── about.html (4.2 KB) 31 + ├── docs.html (3.8 KB) 32 + ├── style.css (7.1 KB) 33 + └── script.js (1.9 KB) 34 + 35 + Cold Tier (S3 - Unlimited): 36 + ├── index.html (3.5 KB) 37 + ├── about.html (4.2 KB) 38 + ├── docs.html (3.8 KB) 39 + ├── style.css (7.1 KB) 40 + └── script.js (1.9 KB) 41 + ``` 42 + 43 + This strategy ensures: 44 + - Lightning-fast serving of the homepage (from memory) 45 + - Efficient use of hot tier capacity 46 + - All files are cached on disk for fast local access 47 + - S3 acts as the source of truth for disaster recovery
+86
packages/@wispplace/tiered-storage/example-site/about.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>About - Tiered Storage Demo</title> 7 + <link rel="stylesheet" href="style.css"> 8 + </head> 9 + <body> 10 + <header> 11 + <nav> 12 + <div class="logo">🗄️ TieredCache</div> 13 + <ul> 14 + <li><a href="index.html">Home</a></li> 15 + <li><a href="about.html" class="active">About</a></li> 16 + <li><a href="docs.html">Docs</a></li> 17 + </ul> 18 + </nav> 19 + </header> 20 + 21 + <main> 22 + <section class="content"> 23 + <h1>About Tiered Storage</h1> 24 + 25 + <h2>The Problem</h2> 26 + <p>Modern applications need to balance three competing concerns:</p> 27 + <ul> 28 + <li><strong>Speed:</strong> Users expect instant responses</li> 29 + <li><strong>Cost:</strong> Keeping everything in memory is expensive</li> 30 + <li><strong>Reliability:</strong> Data must be durable and recoverable</li> 31 + </ul> 32 + 33 + <h2>The Solution</h2> 34 + <p>Tiered storage provides the best of all worlds by automatically managing data across multiple storage tiers:</p> 35 + 36 + <div class="solution-grid"> 37 + <div class="solution-item"> 38 + <h3>🚀 Performance</h3> 39 + <p>Hot tier (memory) serves critical files like index.html in microseconds</p> 40 + </div> 41 + <div class="solution-item"> 42 + <h3>💰 Cost-Effective</h3> 43 + <p>Warm tier (disk) and cold tier (S3) handle bulk storage efficiently</p> 44 + </div> 45 + <div class="solution-item"> 46 + <h3>🛡️ Reliability</h3> 47 + <p>Cold tier acts as source of truth with automatic backups</p> 48 + </div> 49 + </div> 50 + 51 + <h2>Use Cases</h2> 52 + <div class="use-cases"> 53 + <div class="use-case"> 54 + <h4>Static Site Hosting</h4> 55 + <p>Store index.html in memory for instant serving, while keeping images and videos on disk/S3. Perfect for CDN-like performance on a single server.</p> 56 + </div> 57 + <div class="use-case"> 58 + <h4>Content Delivery</h4> 59 + <p>Automatically promote popular content to hot tier based on access patterns. Rarely accessed content stays in cold storage.</p> 60 + </div> 61 + <div class="use-case"> 62 + <h4>Database Caching</h4> 63 + <p>Cache query results in memory, with overflow to disk and S3. Automatic TTL management keeps data fresh.</p> 64 + </div> 65 + </div> 66 + 67 + <h2>How This Demo Works</h2> 68 + <p>This example site is stored using the tiered-storage library:</p> 69 + <ol> 70 + <li><code>index.html</code> - Stored in all tiers (hot + warm + cold) for instant access</li> 71 + <li><code>about.html</code> - Stored in warm + cold (skips hot to save memory)</li> 72 + <li><code>style.css</code> - Stored in warm + cold</li> 73 + <li><code>script.js</code> - Stored in warm + cold</li> 74 + <li><code>hero-image.jpg</code> - Large file, stored in cold tier only</li> 75 + </ol> 76 + <p>When you request a page, the library automatically checks hot → warm → cold and serves from the fastest available tier.</p> 77 + </section> 78 + </main> 79 + 80 + <footer> 81 + <p>&copy; 2024 Tiered Storage Library. Built with ❤️ for performance.</p> 82 + </footer> 83 + 84 + <script src="script.js"></script> 85 + </body> 86 + </html>
+105
packages/@wispplace/tiered-storage/example-site/docs.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>Documentation - Tiered Storage</title> 7 + <link rel="stylesheet" href="style.css"> 8 + </head> 9 + <body> 10 + <header> 11 + <nav> 12 + <div class="logo">🗄️ TieredCache</div> 13 + <ul> 14 + <li><a href="index.html">Home</a></li> 15 + <li><a href="about.html">About</a></li> 16 + <li><a href="docs.html" class="active">Docs</a></li> 17 + </ul> 18 + </nav> 19 + </header> 20 + 21 + <main> 22 + <section class="content"> 23 + <h1>Quick Start Guide</h1> 24 + 25 + <h2>Installation</h2> 26 + <pre><code>npm install tiered-storage 27 + # or 28 + bun add tiered-storage</code></pre> 29 + 30 + <h2>Basic Usage</h2> 31 + <pre><code>import { TieredStorage, MemoryStorageTier, DiskStorageTier, S3StorageTier } from 'tiered-storage'; 32 + 33 + const storage = new TieredStorage({ 34 + tiers: { 35 + hot: new MemoryStorageTier({ maxSizeBytes: 100 * 1024 * 1024 }), 36 + warm: new DiskStorageTier({ directory: './cache' }), 37 + cold: new S3StorageTier({ 38 + bucket: 'my-bucket', 39 + region: 'us-east-1', 40 + }), 41 + }, 42 + compression: true, 43 + defaultTTL: 14 * 24 * 60 * 60 * 1000, // 14 days 44 + }); 45 + 46 + // Store data 47 + await storage.set('user:123', { name: 'Alice' }); 48 + 49 + // Retrieve data 50 + const user = await storage.get('user:123'); 51 + 52 + // Invalidate by prefix 53 + await storage.invalidate('user:');</code></pre> 54 + 55 + <h2>Selective Tier Placement</h2> 56 + <p>Control which tiers receive specific files:</p> 57 + <pre><code>// Critical file - store in all tiers 58 + await storage.set('index.html', htmlContent); 59 + 60 + // Large file - skip hot tier to save memory 61 + await storage.set('video.mp4', videoData, { 62 + skipTiers: ['hot'] 63 + });</code></pre> 64 + 65 + <h2>Bootstrap on Startup</h2> 66 + <pre><code>// Warm up hot tier from warm tier 67 + await storage.bootstrapHot(1000); // Load top 1000 items 68 + 69 + // Warm up warm tier from cold tier 70 + await storage.bootstrapWarm({ 71 + sinceDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), 72 + limit: 10000, 73 + });</code></pre> 74 + 75 + <h2>Statistics & Monitoring</h2> 76 + <pre><code>const stats = await storage.getStats(); 77 + console.log('Hot tier:', stats.hot); 78 + console.log('Warm tier:', stats.warm); 79 + console.log('Cold tier:', stats.cold); 80 + console.log('Hit rate:', stats.hitRate);</code></pre> 81 + 82 + <h2>API Reference</h2> 83 + <ul> 84 + <li><code>get(key)</code> - Retrieve data</li> 85 + <li><code>getWithMetadata(key)</code> - Retrieve with metadata and source tier</li> 86 + <li><code>set(key, data, options)</code> - Store data</li> 87 + <li><code>delete(key)</code> - Delete from all tiers</li> 88 + <li><code>exists(key)</code> - Check if key exists</li> 89 + <li><code>touch(key, ttlMs)</code> - Renew TTL</li> 90 + <li><code>invalidate(prefix)</code> - Delete by prefix</li> 91 + <li><code>listKeys(prefix)</code> - List keys</li> 92 + <li><code>getStats()</code> - Get statistics</li> 93 + <li><code>bootstrapHot(limit)</code> - Warm up hot tier</li> 94 + <li><code>bootstrapWarm(options)</code> - Warm up warm tier</li> 95 + </ul> 96 + </section> 97 + </main> 98 + 99 + <footer> 100 + <p>&copy; 2024 Tiered Storage Library. Built with ❤️ for performance.</p> 101 + </footer> 102 + 103 + <script src="script.js"></script> 104 + </body> 105 + </html>
+103
packages/@wispplace/tiered-storage/example-site/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>Tiered Storage Demo Site</title> 7 + <link rel="stylesheet" href="style.css"> 8 + </head> 9 + <body> 10 + <header> 11 + <nav> 12 + <div class="logo">🗄️ TieredCache</div> 13 + <ul> 14 + <li><a href="index.html" class="active">Home</a></li> 15 + <li><a href="about.html">About</a></li> 16 + <li><a href="docs.html">Docs</a></li> 17 + </ul> 18 + </nav> 19 + </header> 20 + 21 + <main> 22 + <section class="hero"> 23 + <h1>Lightning-Fast Multi-Tier Caching</h1> 24 + <p>Store your data across memory, disk, and cloud storage with automatic promotion and intelligent eviction.</p> 25 + <div class="cta-buttons"> 26 + <a href="#features" class="btn btn-primary">Learn More</a> 27 + <a href="docs.html" class="btn btn-secondary">Documentation</a> 28 + </div> 29 + </section> 30 + 31 + <section id="features" class="features"> 32 + <h2>Features</h2> 33 + <div class="feature-grid"> 34 + <div class="feature-card"> 35 + <div class="feature-icon">⚡</div> 36 + <h3>Hot Tier (Memory)</h3> 37 + <p>Lightning-fast access with LRU eviction. Perfect for frequently accessed data like index.html.</p> 38 + </div> 39 + <div class="feature-card"> 40 + <div class="feature-icon">💾</div> 41 + <h3>Warm Tier (Disk)</h3> 42 + <p>Fast local storage with configurable eviction policies. Ideal for site assets and media.</p> 43 + </div> 44 + <div class="feature-card"> 45 + <div class="feature-icon">☁️</div> 46 + <h3>Cold Tier (S3)</h3> 47 + <p>Unlimited cloud storage as your source of truth. Supports S3, R2, MinIO, and more.</p> 48 + </div> 49 + </div> 50 + </section> 51 + 52 + <section class="architecture"> 53 + <h2>How It Works</h2> 54 + <div class="tier-diagram"> 55 + <div class="tier hot"> 56 + <div class="tier-label">Hot (Memory)</div> 57 + <div class="tier-content">index.html ✓</div> 58 + </div> 59 + <div class="arrow">↓</div> 60 + <div class="tier warm"> 61 + <div class="tier-label">Warm (Disk)</div> 62 + <div class="tier-content">index.html, style.css, images ✓</div> 63 + </div> 64 + <div class="arrow">↓</div> 65 + <div class="tier cold"> 66 + <div class="tier-label">Cold (S3)</div> 67 + <div class="tier-content">All files (source of truth) ✓</div> 68 + </div> 69 + </div> 70 + <p class="diagram-note">Data cascades down on writes, bubbles up on reads</p> 71 + </section> 72 + 73 + <section class="stats" id="cache-stats"> 74 + <h2>Live Cache Statistics</h2> 75 + <div class="stats-grid"> 76 + <div class="stat-card"> 77 + <div class="stat-value" id="hot-items">-</div> 78 + <div class="stat-label">Hot Tier Items</div> 79 + </div> 80 + <div class="stat-card"> 81 + <div class="stat-value" id="warm-items">-</div> 82 + <div class="stat-label">Warm Tier Items</div> 83 + </div> 84 + <div class="stat-card"> 85 + <div class="stat-value" id="cold-items">-</div> 86 + <div class="stat-label">Cold Tier Items</div> 87 + </div> 88 + <div class="stat-card"> 89 + <div class="stat-value" id="hit-rate">-</div> 90 + <div class="stat-label">Cache Hit Rate</div> 91 + </div> 92 + </div> 93 + </section> 94 + </main> 95 + 96 + <footer> 97 + <p>&copy; 2024 Tiered Storage Library. Built with ❤️ for performance.</p> 98 + <p><small>This is a demo site to showcase the tiered-storage library capabilities.</small></p> 99 + </footer> 100 + 101 + <script src="script.js"></script> 102 + </body> 103 + </html>
+84
packages/@wispplace/tiered-storage/example-site/script.js
··· 1 + /** 2 + * Tiered Storage Demo Site - Client-side JavaScript 3 + * 4 + * This script demonstrates how a static site can interact with 5 + * the tiered storage system (in a real scenario, stats would be 6 + * fetched from a backend API that uses the storage library) 7 + */ 8 + 9 + // Simulated cache statistics 10 + // In a real implementation, this would fetch from your backend 11 + function simulateCacheStats() { 12 + return { 13 + hot: { 14 + items: 1, 15 + bytes: 3547, 16 + hits: 42, 17 + misses: 3, 18 + }, 19 + warm: { 20 + items: 5, 21 + bytes: 127438, 22 + hits: 15, 23 + misses: 2, 24 + }, 25 + cold: { 26 + items: 5, 27 + bytes: 127438, 28 + }, 29 + totalHits: 57, 30 + totalMisses: 5, 31 + hitRate: 0.919, 32 + }; 33 + } 34 + 35 + // Update stats display 36 + function updateStatsDisplay() { 37 + const stats = simulateCacheStats(); 38 + 39 + const hotItems = document.getElementById('hot-items'); 40 + const warmItems = document.getElementById('warm-items'); 41 + const coldItems = document.getElementById('cold-items'); 42 + const hitRate = document.getElementById('hit-rate'); 43 + 44 + if (hotItems) hotItems.textContent = stats.hot.items; 45 + if (warmItems) warmItems.textContent = stats.warm.items; 46 + if (coldItems) coldItems.textContent = stats.cold.items; 47 + if (hitRate) hitRate.textContent = `${(stats.hitRate * 100).toFixed(1)}%`; 48 + } 49 + 50 + // Smooth scrolling for anchor links 51 + document.querySelectorAll('a[href^="#"]').forEach(anchor => { 52 + anchor.addEventListener('click', function (e) { 53 + e.preventDefault(); 54 + const target = document.querySelector(this.getAttribute('href')); 55 + if (target) { 56 + target.scrollIntoView({ 57 + behavior: 'smooth', 58 + block: 'start' 59 + }); 60 + } 61 + }); 62 + }); 63 + 64 + // Initialize stats when page loads 65 + if (document.readyState === 'loading') { 66 + document.addEventListener('DOMContentLoaded', updateStatsDisplay); 67 + } else { 68 + updateStatsDisplay(); 69 + } 70 + 71 + // Update stats periodically (simulate real-time updates) 72 + setInterval(updateStatsDisplay, 5000); 73 + 74 + // Add active class to navigation based on current page 75 + const currentPage = window.location.pathname.split('/').pop() || 'index.html'; 76 + document.querySelectorAll('nav a').forEach(link => { 77 + if (link.getAttribute('href') === currentPage) { 78 + link.classList.add('active'); 79 + } 80 + }); 81 + 82 + // Log page view (in real app, would send to analytics) 83 + console.log(`[TieredCache] Page viewed: ${currentPage}`); 84 + console.log(`[TieredCache] This page was likely served from ${currentPage === 'index.html' ? 'hot tier (memory)' : 'warm tier (disk)'}`);
+439
packages/@wispplace/tiered-storage/example-site/style.css
··· 1 + /* Tiered Storage Demo Site - Stylesheet */ 2 + 3 + :root { 4 + --primary-color: #3b82f6; 5 + --secondary-color: #8b5cf6; 6 + --success-color: #10b981; 7 + --background: #0f172a; 8 + --surface: #1e293b; 9 + --text: #f1f5f9; 10 + --text-muted: #94a3b8; 11 + --border: #334155; 12 + } 13 + 14 + * { 15 + margin: 0; 16 + padding: 0; 17 + box-sizing: border-box; 18 + } 19 + 20 + body { 21 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; 22 + background: var(--background); 23 + color: var(--text); 24 + line-height: 1.6; 25 + min-height: 100vh; 26 + display: flex; 27 + flex-direction: column; 28 + } 29 + 30 + /* Header & Navigation */ 31 + header { 32 + background: var(--surface); 33 + border-bottom: 1px solid var(--border); 34 + position: sticky; 35 + top: 0; 36 + z-index: 100; 37 + } 38 + 39 + nav { 40 + max-width: 1200px; 41 + margin: 0 auto; 42 + padding: 1rem 2rem; 43 + display: flex; 44 + justify-content: space-between; 45 + align-items: center; 46 + } 47 + 48 + .logo { 49 + font-size: 1.5rem; 50 + font-weight: 700; 51 + background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); 52 + -webkit-background-clip: text; 53 + -webkit-text-fill-color: transparent; 54 + background-clip: text; 55 + } 56 + 57 + nav ul { 58 + display: flex; 59 + gap: 2rem; 60 + list-style: none; 61 + } 62 + 63 + nav a { 64 + color: var(--text-muted); 65 + text-decoration: none; 66 + transition: color 0.2s; 67 + padding: 0.5rem 1rem; 68 + border-radius: 0.5rem; 69 + } 70 + 71 + nav a:hover, 72 + nav a.active { 73 + color: var(--text); 74 + background: rgba(59, 130, 246, 0.1); 75 + } 76 + 77 + /* Main Content */ 78 + main { 79 + flex: 1; 80 + max-width: 1200px; 81 + margin: 0 auto; 82 + padding: 2rem; 83 + width: 100%; 84 + } 85 + 86 + /* Hero Section */ 87 + .hero { 88 + text-align: center; 89 + padding: 4rem 2rem; 90 + background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), rgba(139, 92, 246, 0.1)); 91 + border-radius: 1rem; 92 + margin-bottom: 3rem; 93 + } 94 + 95 + .hero h1 { 96 + font-size: 3rem; 97 + margin-bottom: 1rem; 98 + background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); 99 + -webkit-background-clip: text; 100 + -webkit-text-fill-color: transparent; 101 + background-clip: text; 102 + } 103 + 104 + .hero p { 105 + font-size: 1.25rem; 106 + color: var(--text-muted); 107 + max-width: 600px; 108 + margin: 0 auto 2rem; 109 + } 110 + 111 + .cta-buttons { 112 + display: flex; 113 + gap: 1rem; 114 + justify-content: center; 115 + } 116 + 117 + .btn { 118 + padding: 0.75rem 2rem; 119 + border-radius: 0.5rem; 120 + text-decoration: none; 121 + font-weight: 600; 122 + transition: all 0.2s; 123 + display: inline-block; 124 + } 125 + 126 + .btn-primary { 127 + background: var(--primary-color); 128 + color: white; 129 + } 130 + 131 + .btn-primary:hover { 132 + background: #2563eb; 133 + transform: translateY(-2px); 134 + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); 135 + } 136 + 137 + .btn-secondary { 138 + background: var(--surface); 139 + color: var(--text); 140 + border: 1px solid var(--border); 141 + } 142 + 143 + .btn-secondary:hover { 144 + background: var(--border); 145 + } 146 + 147 + /* Features */ 148 + .features { 149 + margin-bottom: 3rem; 150 + } 151 + 152 + .features h2 { 153 + text-align: center; 154 + font-size: 2rem; 155 + margin-bottom: 2rem; 156 + } 157 + 158 + .feature-grid { 159 + display: grid; 160 + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); 161 + gap: 2rem; 162 + } 163 + 164 + .feature-card { 165 + background: var(--surface); 166 + padding: 2rem; 167 + border-radius: 1rem; 168 + border: 1px solid var(--border); 169 + transition: transform 0.2s, box-shadow 0.2s; 170 + } 171 + 172 + .feature-card:hover { 173 + transform: translateY(-4px); 174 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); 175 + } 176 + 177 + .feature-icon { 178 + font-size: 3rem; 179 + margin-bottom: 1rem; 180 + } 181 + 182 + .feature-card h3 { 183 + color: var(--primary-color); 184 + margin-bottom: 0.5rem; 185 + } 186 + 187 + .feature-card p { 188 + color: var(--text-muted); 189 + } 190 + 191 + /* Architecture Diagram */ 192 + .architecture { 193 + margin-bottom: 3rem; 194 + } 195 + 196 + .architecture h2 { 197 + text-align: center; 198 + font-size: 2rem; 199 + margin-bottom: 2rem; 200 + } 201 + 202 + .tier-diagram { 203 + max-width: 600px; 204 + margin: 0 auto; 205 + } 206 + 207 + .tier { 208 + background: var(--surface); 209 + border: 2px solid var(--border); 210 + border-radius: 0.5rem; 211 + padding: 1.5rem; 212 + margin-bottom: 1rem; 213 + } 214 + 215 + .tier.hot { 216 + border-color: #ef4444; 217 + background: linear-gradient(135deg, rgba(239, 68, 68, 0.1), var(--surface)); 218 + } 219 + 220 + .tier.warm { 221 + border-color: #f59e0b; 222 + background: linear-gradient(135deg, rgba(245, 158, 11, 0.1), var(--surface)); 223 + } 224 + 225 + .tier.cold { 226 + border-color: var(--primary-color); 227 + background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), var(--surface)); 228 + } 229 + 230 + .tier-label { 231 + font-weight: 700; 232 + margin-bottom: 0.5rem; 233 + font-size: 1.1rem; 234 + } 235 + 236 + .tier-content { 237 + color: var(--text-muted); 238 + font-size: 0.9rem; 239 + } 240 + 241 + .arrow { 242 + text-align: center; 243 + font-size: 2rem; 244 + color: var(--text-muted); 245 + margin: -0.5rem 0; 246 + } 247 + 248 + .diagram-note { 249 + text-align: center; 250 + color: var(--text-muted); 251 + font-style: italic; 252 + margin-top: 1rem; 253 + } 254 + 255 + /* Stats */ 256 + .stats { 257 + margin-bottom: 3rem; 258 + } 259 + 260 + .stats h2 { 261 + text-align: center; 262 + font-size: 2rem; 263 + margin-bottom: 2rem; 264 + } 265 + 266 + .stats-grid { 267 + display: grid; 268 + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 269 + gap: 1.5rem; 270 + } 271 + 272 + .stat-card { 273 + background: var(--surface); 274 + padding: 2rem; 275 + border-radius: 1rem; 276 + border: 1px solid var(--border); 277 + text-align: center; 278 + } 279 + 280 + .stat-value { 281 + font-size: 2.5rem; 282 + font-weight: 700; 283 + color: var(--primary-color); 284 + margin-bottom: 0.5rem; 285 + } 286 + 287 + .stat-label { 288 + color: var(--text-muted); 289 + font-size: 0.9rem; 290 + } 291 + 292 + /* Content Pages */ 293 + .content { 294 + max-width: 800px; 295 + margin: 0 auto; 296 + } 297 + 298 + .content h1 { 299 + font-size: 2.5rem; 300 + margin-bottom: 1.5rem; 301 + color: var(--primary-color); 302 + } 303 + 304 + .content h2 { 305 + font-size: 1.8rem; 306 + margin-top: 2rem; 307 + margin-bottom: 1rem; 308 + } 309 + 310 + .content h3 { 311 + font-size: 1.3rem; 312 + margin-top: 1.5rem; 313 + margin-bottom: 0.5rem; 314 + } 315 + 316 + .content h4 { 317 + font-size: 1.1rem; 318 + margin-top: 1rem; 319 + margin-bottom: 0.5rem; 320 + color: var(--primary-color); 321 + } 322 + 323 + .content p { 324 + margin-bottom: 1rem; 325 + color: var(--text-muted); 326 + } 327 + 328 + .content ul, .content ol { 329 + margin-bottom: 1rem; 330 + margin-left: 2rem; 331 + color: var(--text-muted); 332 + } 333 + 334 + .content li { 335 + margin-bottom: 0.5rem; 336 + } 337 + 338 + .content code { 339 + background: var(--surface); 340 + padding: 0.2rem 0.5rem; 341 + border-radius: 0.25rem; 342 + font-family: 'Monaco', 'Courier New', monospace; 343 + font-size: 0.9em; 344 + color: var(--success-color); 345 + } 346 + 347 + .content pre { 348 + background: var(--surface); 349 + padding: 1.5rem; 350 + border-radius: 0.5rem; 351 + overflow-x: auto; 352 + margin-bottom: 1rem; 353 + border: 1px solid var(--border); 354 + } 355 + 356 + .content pre code { 357 + background: none; 358 + padding: 0; 359 + color: var(--text); 360 + } 361 + 362 + .solution-grid { 363 + display: grid; 364 + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); 365 + gap: 1.5rem; 366 + margin: 2rem 0; 367 + } 368 + 369 + .solution-item { 370 + background: var(--surface); 371 + padding: 1.5rem; 372 + border-radius: 0.5rem; 373 + border: 1px solid var(--border); 374 + } 375 + 376 + .solution-item h3 { 377 + margin-top: 0; 378 + } 379 + 380 + .use-cases { 381 + display: flex; 382 + flex-direction: column; 383 + gap: 1.5rem; 384 + margin-top: 2rem; 385 + } 386 + 387 + .use-case { 388 + background: var(--surface); 389 + padding: 1.5rem; 390 + border-radius: 0.5rem; 391 + border-left: 4px solid var(--primary-color); 392 + } 393 + 394 + .use-case h4 { 395 + margin-top: 0; 396 + } 397 + 398 + /* Footer */ 399 + footer { 400 + background: var(--surface); 401 + border-top: 1px solid var(--border); 402 + padding: 2rem; 403 + text-align: center; 404 + color: var(--text-muted); 405 + margin-top: auto; 406 + } 407 + 408 + footer p { 409 + margin: 0.5rem 0; 410 + } 411 + 412 + /* Responsive */ 413 + @media (max-width: 768px) { 414 + .hero h1 { 415 + font-size: 2rem; 416 + } 417 + 418 + .hero p { 419 + font-size: 1rem; 420 + } 421 + 422 + nav ul { 423 + gap: 1rem; 424 + } 425 + 426 + .feature-grid, 427 + .stats-grid { 428 + grid-template-columns: 1fr; 429 + } 430 + 431 + .solution-grid { 432 + grid-template-columns: 1fr; 433 + } 434 + 435 + .cta-buttons { 436 + flex-direction: column; 437 + align-items: stretch; 438 + } 439 + }
+535
packages/@wispplace/tiered-storage/example.ts
··· 1 + /** 2 + * Example usage of the tiered-storage library 3 + * 4 + * Run with: bun run example 5 + * 6 + * Note: This example uses S3 for cold storage. You'll need to configure 7 + * AWS credentials and an S3 bucket in .env (see .env.example) 8 + */ 9 + 10 + import { TieredStorage, MemoryStorageTier, DiskStorageTier, S3StorageTier } from './src/index.js'; 11 + import { rm } from 'node:fs/promises'; 12 + 13 + // Configuration from environment variables 14 + const S3_BUCKET = process.env.S3_BUCKET || 'tiered-storage-example'; 15 + const S3_REGION = process.env.S3_REGION || 'us-east-1'; 16 + const S3_ENDPOINT = process.env.S3_ENDPOINT; 17 + const S3_FORCE_PATH_STYLE = process.env.S3_FORCE_PATH_STYLE !== 'false'; // Default true 18 + const AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID; 19 + const AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY; 20 + 21 + async function basicExample() { 22 + console.log('\n=== Basic Example ===\n'); 23 + 24 + const storage = new TieredStorage({ 25 + tiers: { 26 + hot: new MemoryStorageTier({ maxSizeBytes: 10 * 1024 * 1024 }), // 10MB 27 + warm: new DiskStorageTier({ directory: './example-cache/basic/warm' }), 28 + cold: new S3StorageTier({ 29 + bucket: S3_BUCKET, 30 + region: S3_REGION, 31 + endpoint: S3_ENDPOINT, 32 + forcePathStyle: S3_FORCE_PATH_STYLE, 33 + credentials: 34 + AWS_ACCESS_KEY_ID && AWS_SECRET_ACCESS_KEY 35 + ? { 36 + accessKeyId: AWS_ACCESS_KEY_ID, 37 + secretAccessKey: AWS_SECRET_ACCESS_KEY, 38 + } 39 + : undefined, 40 + prefix: 'example/basic/', 41 + }), 42 + }, 43 + compression: true, 44 + defaultTTL: 60 * 60 * 1000, // 1 hour 45 + }); 46 + 47 + // Store some data 48 + console.log('Storing user data...'); 49 + await storage.set('user:alice', { 50 + name: 'Alice', 51 + email: 'alice@example.com', 52 + role: 'admin', 53 + }); 54 + 55 + await storage.set('user:bob', { 56 + name: 'Bob', 57 + email: 'bob@example.com', 58 + role: 'user', 59 + }); 60 + 61 + // Retrieve with metadata 62 + const result = await storage.getWithMetadata('user:alice'); 63 + if (result) { 64 + console.log(`Retrieved user:alice from ${result.source} tier:`); 65 + console.log(result.data); 66 + console.log('Metadata:', { 67 + size: result.metadata.size, 68 + compressed: result.metadata.compressed, 69 + accessCount: result.metadata.accessCount, 70 + }); 71 + } 72 + 73 + // Get statistics 74 + const stats = await storage.getStats(); 75 + console.log('\nStorage Statistics:'); 76 + console.log(`Hot tier: ${stats.hot?.items} items, ${stats.hot?.bytes} bytes`); 77 + console.log(`Warm tier: ${stats.warm?.items} items, ${stats.warm?.bytes} bytes`); 78 + console.log(`Cold tier (S3): ${stats.cold.items} items, ${stats.cold.bytes} bytes`); 79 + console.log(`Hit rate: ${(stats.hitRate * 100).toFixed(2)}%`); 80 + 81 + // List all keys with prefix 82 + console.log('\nAll user keys:'); 83 + for await (const key of storage.listKeys('user:')) { 84 + console.log(` - ${key}`); 85 + } 86 + 87 + // Invalidate by prefix 88 + console.log('\nInvalidating all user keys...'); 89 + const deleted = await storage.invalidate('user:'); 90 + console.log(`Deleted ${deleted} keys`); 91 + } 92 + 93 + async function staticSiteHostingExample() { 94 + console.log('\n=== Static Site Hosting Example (wisp.place pattern) ===\n'); 95 + 96 + const storage = new TieredStorage({ 97 + tiers: { 98 + hot: new MemoryStorageTier({ 99 + maxSizeBytes: 50 * 1024 * 1024, // 50MB 100 + maxItems: 500, 101 + }), 102 + warm: new DiskStorageTier({ 103 + directory: './example-cache/sites/warm', 104 + maxSizeBytes: 1024 * 1024 * 1024, // 1GB 105 + }), 106 + cold: new S3StorageTier({ 107 + bucket: S3_BUCKET, 108 + region: S3_REGION, 109 + endpoint: S3_ENDPOINT, 110 + forcePathStyle: S3_FORCE_PATH_STYLE, 111 + credentials: 112 + AWS_ACCESS_KEY_ID && AWS_SECRET_ACCESS_KEY 113 + ? { 114 + accessKeyId: AWS_ACCESS_KEY_ID, 115 + secretAccessKey: AWS_SECRET_ACCESS_KEY, 116 + } 117 + : undefined, 118 + prefix: 'example/sites/', 119 + }), 120 + }, 121 + compression: true, 122 + defaultTTL: 14 * 24 * 60 * 60 * 1000, // 14 days 123 + promotionStrategy: 'lazy', // Don't auto-promote large files 124 + }); 125 + 126 + const siteId = 'did:plc:abc123'; 127 + const siteName = 'tiered-cache-demo'; 128 + 129 + console.log('Loading real static site from example-site/...\n'); 130 + 131 + // Load actual site files 132 + const { readFile } = await import('node:fs/promises'); 133 + 134 + const files = [ 135 + { name: 'index.html', skipTiers: [], mimeType: 'text/html' }, 136 + { name: 'about.html', skipTiers: ['hot'], mimeType: 'text/html' }, 137 + { name: 'docs.html', skipTiers: ['hot'], mimeType: 'text/html' }, 138 + { name: 'style.css', skipTiers: ['hot'], mimeType: 'text/css' }, 139 + { name: 'script.js', skipTiers: ['hot'], mimeType: 'application/javascript' }, 140 + ]; 141 + 142 + console.log('Storing site files with selective tier placement:\n'); 143 + 144 + for (const file of files) { 145 + const content = await readFile(`./example-site/${file.name}`, 'utf-8'); 146 + const key = `${siteId}/${siteName}/${file.name}`; 147 + 148 + await storage.set(key, content, { 149 + skipTiers: file.skipTiers as ('hot' | 'warm')[], 150 + metadata: { mimeType: file.mimeType }, 151 + }); 152 + 153 + const tierInfo = 154 + file.skipTiers.length === 0 155 + ? 'hot + warm + cold (S3)' 156 + : `warm + cold (S3) - skipped ${file.skipTiers.join(', ')}`; 157 + const sizeKB = (content.length / 1024).toFixed(2); 158 + console.log(`✓ ${file.name} (${sizeKB} KB) → ${tierInfo}`); 159 + } 160 + 161 + // Check where each file is served from 162 + console.log('\nServing files (checking which tier):'); 163 + for (const file of files) { 164 + const result = await storage.getWithMetadata(`${siteId}/${siteName}/${file.name}`); 165 + if (result) { 166 + const sizeKB = (result.metadata.size / 1024).toFixed(2); 167 + console.log(` ${file.name}: served from ${result.source} (${sizeKB} KB)`); 168 + } 169 + } 170 + 171 + // Show hot tier only has index.html 172 + console.log('\nHot tier contents (should only contain index.html):'); 173 + const stats = await storage.getStats(); 174 + console.log(` Items: ${stats.hot?.items}`); 175 + console.log(` Size: ${((stats.hot?.bytes ?? 0) / 1024).toFixed(2)} KB`); 176 + console.log(` Files: index.html only`); 177 + 178 + console.log('\nWarm tier contents (all site files):'); 179 + console.log(` Items: ${stats.warm?.items}`); 180 + console.log(` Size: ${((stats.warm?.bytes ?? 0) / 1024).toFixed(2)} KB`); 181 + console.log(` Files: all ${files.length} files`); 182 + 183 + // Demonstrate accessing a page 184 + console.log('\nSimulating page request for about.html:'); 185 + const aboutPage = await storage.getWithMetadata(`${siteId}/${siteName}/about.html`); 186 + if (aboutPage) { 187 + console.log(` Source: ${aboutPage.source} tier`); 188 + console.log(` Access count: ${aboutPage.metadata.accessCount}`); 189 + console.log(` Preview: ${aboutPage.data.toString().slice(0, 100)}...`); 190 + } 191 + 192 + // Invalidate entire site 193 + console.log(`\nInvalidating entire site: ${siteId}/${siteName}/`); 194 + const deleted = await storage.invalidate(`${siteId}/${siteName}/`); 195 + console.log(`Deleted ${deleted} files from all tiers`); 196 + } 197 + 198 + async function bootstrapExample() { 199 + console.log('\n=== Bootstrap Example ===\n'); 200 + 201 + const hot = new MemoryStorageTier({ maxSizeBytes: 10 * 1024 * 1024 }); 202 + const warm = new DiskStorageTier({ directory: './example-cache/bootstrap/warm' }); 203 + const cold = new S3StorageTier({ 204 + bucket: S3_BUCKET, 205 + region: S3_REGION, 206 + endpoint: S3_ENDPOINT, 207 + forcePathStyle: S3_FORCE_PATH_STYLE, 208 + credentials: 209 + AWS_ACCESS_KEY_ID && AWS_SECRET_ACCESS_KEY 210 + ? { 211 + accessKeyId: AWS_ACCESS_KEY_ID, 212 + secretAccessKey: AWS_SECRET_ACCESS_KEY, 213 + } 214 + : undefined, 215 + prefix: 'example/bootstrap/', 216 + }); 217 + 218 + const storage = new TieredStorage({ 219 + tiers: { hot, warm, cold }, 220 + }); 221 + 222 + // Populate with some data 223 + console.log('Populating storage with test data...'); 224 + for (let i = 0; i < 10; i++) { 225 + await storage.set(`item:${i}`, { 226 + id: i, 227 + name: `Item ${i}`, 228 + description: `This is item number ${i}`, 229 + }); 230 + } 231 + 232 + // Access some items to build up access counts 233 + console.log('Accessing some items to simulate usage patterns...'); 234 + await storage.get('item:0'); // Most accessed 235 + await storage.get('item:0'); 236 + await storage.get('item:0'); 237 + await storage.get('item:1'); // Second most accessed 238 + await storage.get('item:1'); 239 + await storage.get('item:2'); // Third most accessed 240 + 241 + // Clear hot tier to simulate server restart 242 + console.log('\nSimulating server restart (clearing hot tier)...'); 243 + await hot.clear(); 244 + 245 + let hotStats = await hot.getStats(); 246 + console.log(`Hot tier after clear: ${hotStats.items} items`); 247 + 248 + // Bootstrap hot from warm (loads most accessed items) 249 + console.log('\nBootstrapping hot tier from warm (loading top 3 items)...'); 250 + const loaded = await storage.bootstrapHot(3); 251 + console.log(`Loaded ${loaded} items into hot tier`); 252 + 253 + hotStats = await hot.getStats(); 254 + console.log(`Hot tier after bootstrap: ${hotStats.items} items`); 255 + 256 + // Verify the right items were loaded 257 + console.log('\nVerifying loaded items are served from hot:'); 258 + for (let i = 0; i < 3; i++) { 259 + const result = await storage.getWithMetadata(`item:${i}`); 260 + console.log(` item:${i}: ${result?.source}`); 261 + } 262 + 263 + // Cleanup this example's data 264 + console.log('\nCleaning up bootstrap example data...'); 265 + await storage.invalidate('item:'); 266 + } 267 + 268 + async function streamingExample() { 269 + console.log('\n=== Streaming Example ===\n'); 270 + 271 + const { createReadStream, statSync } = await import('node:fs'); 272 + const { pipeline } = await import('node:stream/promises'); 273 + 274 + const storage = new TieredStorage({ 275 + tiers: { 276 + hot: new MemoryStorageTier({ maxSizeBytes: 10 * 1024 * 1024 }), // 10MB 277 + warm: new DiskStorageTier({ directory: './example-cache/streaming/warm' }), 278 + cold: new S3StorageTier({ 279 + bucket: S3_BUCKET, 280 + region: S3_REGION, 281 + endpoint: S3_ENDPOINT, 282 + forcePathStyle: S3_FORCE_PATH_STYLE, 283 + credentials: 284 + AWS_ACCESS_KEY_ID && AWS_SECRET_ACCESS_KEY 285 + ? { 286 + accessKeyId: AWS_ACCESS_KEY_ID, 287 + secretAccessKey: AWS_SECRET_ACCESS_KEY, 288 + } 289 + : undefined, 290 + prefix: 'example/streaming/', 291 + }), 292 + }, 293 + compression: true, // Streams will be compressed automatically 294 + defaultTTL: 60 * 60 * 1000, // 1 hour 295 + }); 296 + 297 + // Stream a file to storage 298 + const filePath = './example-site/index.html'; 299 + const fileStats = statSync(filePath); 300 + 301 + console.log(`Streaming ${filePath} (${fileStats.size} bytes) with compression...`); 302 + 303 + const readStream = createReadStream(filePath); 304 + const result = await storage.setStream('streaming/index.html', readStream, { 305 + size: fileStats.size, 306 + mimeType: 'text/html', 307 + }); 308 + 309 + console.log(`✓ Stored with key: ${result.key}`); 310 + console.log(` Original size: ${result.metadata.size} bytes`); 311 + console.log(` Compressed: ${result.metadata.compressed}`); 312 + console.log(` Checksum (original data): ${result.metadata.checksum.slice(0, 16)}...`); 313 + console.log(` Written to tiers: ${result.tiersWritten.join(', ')}`); 314 + 315 + // Stream the file back (automatically decompressed) 316 + console.log('\nStreaming back the file (with automatic decompression)...'); 317 + 318 + const streamResult = await storage.getStream('streaming/index.html'); 319 + if (streamResult) { 320 + console.log(`✓ Streaming from: ${streamResult.source} tier`); 321 + console.log(` Metadata size: ${streamResult.metadata.size} bytes`); 322 + console.log(` Compressed in storage: ${streamResult.metadata.compressed}`); 323 + 324 + // Collect stream data to verify content 325 + const chunks: Buffer[] = []; 326 + for await (const chunk of streamResult.stream) { 327 + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); 328 + } 329 + const content = Buffer.concat(chunks); 330 + 331 + console.log(` Retrieved size: ${content.length} bytes`); 332 + console.log(` Content preview: ${content.toString('utf-8').slice(0, 100)}...`); 333 + 334 + // Verify the content matches the original 335 + const { readFile } = await import('node:fs/promises'); 336 + const original = await readFile(filePath); 337 + if (content.equals(original)) { 338 + console.log(' ✓ Content matches original file!'); 339 + } else { 340 + console.log(' ✗ Content does NOT match original file'); 341 + } 342 + } 343 + 344 + // Example: Stream to a writable destination (like an HTTP response) 345 + console.log('\nStreaming to destination (simulated HTTP response)...'); 346 + const streamResult2 = await storage.getStream('streaming/index.html'); 347 + if (streamResult2) { 348 + // In a real server, you would do: streamResult2.stream.pipe(res); 349 + // Here we just demonstrate the pattern 350 + const { Writable } = await import('node:stream'); 351 + let totalBytes = 0; 352 + const mockResponse = new Writable({ 353 + write(chunk, _encoding, callback) { 354 + totalBytes += chunk.length; 355 + callback(); 356 + }, 357 + }); 358 + 359 + await pipeline(streamResult2.stream, mockResponse); 360 + console.log(`✓ Streamed ${totalBytes} bytes to destination`); 361 + } 362 + 363 + // Cleanup 364 + console.log('\nCleaning up streaming example data...'); 365 + await storage.invalidate('streaming/'); 366 + } 367 + 368 + async function promotionStrategyExample() { 369 + console.log('\n=== Promotion Strategy Example ===\n'); 370 + 371 + // Lazy promotion (default) 372 + console.log('Testing LAZY promotion:'); 373 + const lazyStorage = new TieredStorage({ 374 + tiers: { 375 + hot: new MemoryStorageTier({ maxSizeBytes: 10 * 1024 * 1024 }), 376 + warm: new DiskStorageTier({ directory: './example-cache/promo-lazy/warm' }), 377 + cold: new S3StorageTier({ 378 + bucket: S3_BUCKET, 379 + region: S3_REGION, 380 + endpoint: S3_ENDPOINT, 381 + forcePathStyle: S3_FORCE_PATH_STYLE, 382 + credentials: 383 + AWS_ACCESS_KEY_ID && AWS_SECRET_ACCESS_KEY 384 + ? { 385 + accessKeyId: AWS_ACCESS_KEY_ID, 386 + secretAccessKey: AWS_SECRET_ACCESS_KEY, 387 + } 388 + : undefined, 389 + prefix: 'example/promo-lazy/', 390 + }), 391 + }, 392 + promotionStrategy: 'lazy', 393 + }); 394 + 395 + // Write data and clear hot 396 + await lazyStorage.set('test:lazy', { value: 'lazy test' }); 397 + await lazyStorage.clearTier('hot'); 398 + 399 + // Read from cold (should NOT auto-promote to hot) 400 + const lazyResult = await lazyStorage.getWithMetadata('test:lazy'); 401 + console.log(` First read served from: ${lazyResult?.source}`); 402 + 403 + const lazyResult2 = await lazyStorage.getWithMetadata('test:lazy'); 404 + console.log(` Second read served from: ${lazyResult2?.source} (lazy = no auto-promotion)`); 405 + 406 + // Eager promotion 407 + console.log('\nTesting EAGER promotion:'); 408 + const eagerStorage = new TieredStorage({ 409 + tiers: { 410 + hot: new MemoryStorageTier({ maxSizeBytes: 10 * 1024 * 1024 }), 411 + warm: new DiskStorageTier({ directory: './example-cache/promo-eager/warm' }), 412 + cold: new S3StorageTier({ 413 + bucket: S3_BUCKET, 414 + region: S3_REGION, 415 + endpoint: S3_ENDPOINT, 416 + forcePathStyle: S3_FORCE_PATH_STYLE, 417 + credentials: 418 + AWS_ACCESS_KEY_ID && AWS_SECRET_ACCESS_KEY 419 + ? { 420 + accessKeyId: AWS_ACCESS_KEY_ID, 421 + secretAccessKey: AWS_SECRET_ACCESS_KEY, 422 + } 423 + : undefined, 424 + prefix: 'example/promo-eager/', 425 + }), 426 + }, 427 + promotionStrategy: 'eager', 428 + }); 429 + 430 + // Write data and clear hot 431 + await eagerStorage.set('test:eager', { value: 'eager test' }); 432 + await eagerStorage.clearTier('hot'); 433 + 434 + // Read from cold (SHOULD auto-promote to hot) 435 + const eagerResult = await eagerStorage.getWithMetadata('test:eager'); 436 + console.log(` First read served from: ${eagerResult?.source}`); 437 + 438 + const eagerResult2 = await eagerStorage.getWithMetadata('test:eager'); 439 + console.log(` Second read served from: ${eagerResult2?.source} (eager = promoted to hot)`); 440 + 441 + // Cleanup 442 + await lazyStorage.invalidate('test:'); 443 + await eagerStorage.invalidate('test:'); 444 + } 445 + 446 + async function cleanup() { 447 + console.log('\n=== Cleanup ===\n'); 448 + console.log('Removing example cache directories...'); 449 + await rm('./example-cache', { recursive: true, force: true }); 450 + console.log('✓ Local cache directories removed'); 451 + console.log('\nNote: S3 objects with prefix "example/" remain in bucket'); 452 + console.log(' (remove manually if needed)'); 453 + } 454 + 455 + async function main() { 456 + console.log('╔════════════════════════════════════════════════╗'); 457 + console.log('║ Tiered Storage Library - Usage Examples ║'); 458 + console.log('║ Cold Tier: S3 (or S3-compatible storage) ║'); 459 + console.log('╚════════════════════════════════════════════════╝'); 460 + 461 + // Check for S3 configuration 462 + if (!AWS_ACCESS_KEY_ID || !AWS_SECRET_ACCESS_KEY) { 463 + console.log('\n⚠️ Warning: AWS credentials not configured'); 464 + console.log(' Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in .env'); 465 + console.log(' (See .env.example for configuration options)\n'); 466 + } 467 + 468 + console.log('\nConfiguration:'); 469 + console.log(` S3 Bucket: ${S3_BUCKET}`); 470 + console.log(` S3 Region: ${S3_REGION}`); 471 + console.log(` S3 Endpoint: ${S3_ENDPOINT || '(default AWS S3)'}`); 472 + console.log(` Force Path Style: ${S3_FORCE_PATH_STYLE}`); 473 + console.log(` Credentials: ${AWS_ACCESS_KEY_ID ? '✓ Configured' : '✗ Not configured (using IAM role)'}`); 474 + 475 + try { 476 + // Test S3 connection first 477 + console.log('\nTesting S3 connection...'); 478 + const testStorage = new S3StorageTier({ 479 + bucket: S3_BUCKET, 480 + region: S3_REGION, 481 + endpoint: S3_ENDPOINT, 482 + forcePathStyle: S3_FORCE_PATH_STYLE, 483 + credentials: 484 + AWS_ACCESS_KEY_ID && AWS_SECRET_ACCESS_KEY 485 + ? { 486 + accessKeyId: AWS_ACCESS_KEY_ID, 487 + secretAccessKey: AWS_SECRET_ACCESS_KEY, 488 + } 489 + : undefined, 490 + prefix: 'test/', 491 + }); 492 + 493 + try { 494 + await testStorage.set('connection-test', new TextEncoder().encode('test'), { 495 + key: 'connection-test', 496 + size: 4, 497 + createdAt: new Date(), 498 + lastAccessed: new Date(), 499 + accessCount: 0, 500 + compressed: false, 501 + checksum: 'test', 502 + }); 503 + console.log('✓ S3 connection successful!\n'); 504 + await testStorage.delete('connection-test'); 505 + } catch (error: any) { 506 + console.error('✗ S3 connection failed:', error.message); 507 + console.error('\nPossible issues:'); 508 + console.error(' 1. Check that the bucket exists on your S3 service'); 509 + console.error(' 2. Verify credentials have read/write permissions'); 510 + console.error(' 3. Confirm the endpoint URL is correct'); 511 + console.error(' 4. Try setting S3_REGION to a different value (e.g., "us-east-1" or "auto")'); 512 + console.error('\nSkipping examples due to S3 connection error.\n'); 513 + return; 514 + } 515 + 516 + await basicExample(); 517 + await staticSiteHostingExample(); 518 + await streamingExample(); 519 + await bootstrapExample(); 520 + await promotionStrategyExample(); 521 + } catch (error: any) { 522 + console.error('\n❌ Error:', error.message); 523 + if (error.name === 'NoSuchBucket') { 524 + console.error(`\n The S3 bucket "${S3_BUCKET}" does not exist.`); 525 + console.error(' Create it first or set S3_BUCKET in .env to an existing bucket.\n'); 526 + } 527 + } finally { 528 + await cleanup(); 529 + } 530 + 531 + console.log('\n✅ All examples completed successfully!'); 532 + console.log('\nTry modifying this file to experiment with different patterns.'); 533 + } 534 + 535 + main().catch(console.error);
+54
packages/@wispplace/tiered-storage/package.json
··· 1 + { 2 + "name": "@wispplace/tiered-storage", 3 + "version": "1.0.0", 4 + "private": true, 5 + "description": "Tiered storage library with S3, disk, and memory caching", 6 + "main": "./src/index.ts", 7 + "types": "./src/index.ts", 8 + "type": "module", 9 + "exports": { 10 + ".": { 11 + "types": "./src/index.ts", 12 + "default": "./src/index.ts" 13 + } 14 + }, 15 + "scripts": { 16 + "check": "tsc --noEmit", 17 + "build": "tsc", 18 + "dev": "tsx --watch src/index.ts", 19 + "example": "tsx example.ts", 20 + "serve": "tsx serve-example.ts", 21 + "test": "vitest", 22 + "test:watch": "vitest --watch", 23 + "lint": "eslint src test --ext .ts", 24 + "lint:fix": "eslint src test --ext .ts --fix", 25 + "typecheck": "tsc --noEmit", 26 + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 27 + "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"" 28 + }, 29 + "dependencies": { 30 + "@aws-sdk/client-s3": "^3.500.0", 31 + "@aws-sdk/lib-storage": "^3.500.0", 32 + "hono": "^4.10.7", 33 + "mime-types": "^3.0.2", 34 + "tiny-lru": "^11.0.0" 35 + }, 36 + "devDependencies": { 37 + "@types/bun": "^1.3.4", 38 + "@types/mime-types": "^3.0.1", 39 + "@types/node": "^24.10.1", 40 + "@typescript-eslint/eslint-plugin": "^8.48.1", 41 + "@typescript-eslint/parser": "^8.48.1", 42 + "eslint": "^9.39.1", 43 + "eslint-config-prettier": "^10.1.8", 44 + "eslint-plugin-prettier": "^5.5.4", 45 + "prettier": "^3.7.4", 46 + "tsx": "^4.0.0", 47 + "typescript": "^5.3.0", 48 + "typescript-eslint": "^8.50.0", 49 + "vitest": "^4.0.15" 50 + }, 51 + "engines": { 52 + "node": ">=18.0.0" 53 + } 54 + }
+476
packages/@wispplace/tiered-storage/serve-example.ts
··· 1 + /** 2 + * Example HTTP server serving static sites from tiered storage 3 + * 4 + * This demonstrates a real-world use case: serving static websites 5 + * with automatic caching across hot (memory), warm (disk), and cold (S3) tiers. 6 + * 7 + * Run with: bun run serve 8 + */ 9 + 10 + import { Hono } from 'hono'; 11 + import { TieredStorage, MemoryStorageTier, DiskStorageTier, S3StorageTier } from './src/index.js'; 12 + import { readFile, readdir } from 'node:fs/promises'; 13 + import { lookup } from 'mime-types'; 14 + 15 + const S3_BUCKET = process.env.S3_BUCKET || 'tiered-storage-example'; 16 + const S3_METADATA_BUCKET = process.env.S3_METADATA_BUCKET; 17 + const S3_REGION = process.env.S3_REGION || 'us-east-1'; 18 + const S3_ENDPOINT = process.env.S3_ENDPOINT; 19 + const S3_FORCE_PATH_STYLE = process.env.S3_FORCE_PATH_STYLE !== 'false'; 20 + const AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID; 21 + const AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY; 22 + const PORT = parseInt(process.env.PORT || '3000', 10); 23 + 24 + const storage = new TieredStorage({ 25 + tiers: { 26 + hot: new MemoryStorageTier({ 27 + maxSizeBytes: 50 * 1024 * 1024, 28 + maxItems: 500, 29 + }), 30 + warm: new DiskStorageTier({ 31 + directory: './cache/sites', 32 + maxSizeBytes: 1024 * 1024 * 1024, 33 + }), 34 + cold: new S3StorageTier({ 35 + bucket: S3_BUCKET, 36 + metadataBucket: S3_METADATA_BUCKET, 37 + region: S3_REGION, 38 + endpoint: S3_ENDPOINT, 39 + forcePathStyle: S3_FORCE_PATH_STYLE, 40 + credentials: 41 + AWS_ACCESS_KEY_ID && AWS_SECRET_ACCESS_KEY 42 + ? { accessKeyId: AWS_ACCESS_KEY_ID, secretAccessKey: AWS_SECRET_ACCESS_KEY } 43 + : undefined, 44 + prefix: 'demo-sites/', 45 + }), 46 + }, 47 + placementRules: [ 48 + // index.html goes to all tiers for instant serving 49 + { pattern: '**/index.html', tiers: ['hot', 'warm', 'cold'] }, 50 + 51 + // everything else: warm + cold only 52 + { pattern: '**', tiers: ['warm', 'cold'] }, 53 + ], 54 + compression: true, 55 + defaultTTL: 14 * 24 * 60 * 60 * 1000, 56 + promotionStrategy: 'lazy', 57 + }); 58 + 59 + const app = new Hono(); 60 + 61 + // Site metadata 62 + const siteId = 'did:plc:example123'; 63 + const siteName = 'tiered-cache-demo'; 64 + 65 + /** 66 + * Load the example site into storage 67 + */ 68 + async function loadExampleSite() { 69 + console.log('\n📦 Loading example site into tiered storage...\n'); 70 + 71 + const files = [ 72 + { name: 'index.html', mimeType: 'text/html' }, 73 + { name: 'about.html', mimeType: 'text/html' }, 74 + { name: 'docs.html', mimeType: 'text/html' }, 75 + { name: 'style.css', mimeType: 'text/css' }, 76 + { name: 'script.js', mimeType: 'application/javascript' }, 77 + ]; 78 + 79 + for (const file of files) { 80 + const content = await readFile(`./example-site/${file.name}`, 'utf-8'); 81 + const key = `${siteId}/${siteName}/${file.name}`; 82 + 83 + await storage.set(key, content, { 84 + metadata: { mimeType: file.mimeType }, 85 + }); 86 + 87 + // Determine which tiers this file went to based on placement rules 88 + const isIndex = file.name === 'index.html'; 89 + const tierInfo = isIndex 90 + ? '🔥 hot + 💾 warm + ☁️ cold' 91 + : '💾 warm + ☁️ cold (skipped hot)'; 92 + const sizeKB = (content.length / 1024).toFixed(2); 93 + console.log(` ✓ ${file.name.padEnd(15)} ${sizeKB.padStart(6)} KB → ${tierInfo}`); 94 + } 95 + 96 + console.log('\n✅ Site loaded successfully!\n'); 97 + } 98 + 99 + /** 100 + * Serve a file from tiered storage 101 + */ 102 + app.get('/sites/:did/:siteName/:path{.*}', async (c) => { 103 + const { did, siteName, path } = c.req.param(); 104 + let filePath = path || 'index.html'; 105 + 106 + if (filePath === '' || filePath.endsWith('/')) { 107 + filePath += 'index.html'; 108 + } 109 + 110 + const key = `${did}/${siteName}/${filePath}`; 111 + 112 + try { 113 + const result = await storage.getWithMetadata(key); 114 + 115 + if (!result) { 116 + return c.text('404 Not Found', 404); 117 + } 118 + 119 + const mimeType = result.metadata.customMetadata?.mimeType || lookup(filePath) || 'application/octet-stream'; 120 + 121 + const headers: Record<string, string> = { 122 + 'Content-Type': mimeType, 123 + 'X-Cache-Tier': result.source, // Which tier served this 124 + 'X-Cache-Size': result.metadata.size.toString(), 125 + 'X-Cache-Compressed': result.metadata.compressed.toString(), 126 + 'X-Cache-Access-Count': result.metadata.accessCount.toString(), 127 + }; 128 + 129 + // Add cache control based on tier 130 + if (result.source === 'hot') { 131 + headers['X-Cache-Status'] = 'HIT-MEMORY'; 132 + } else if (result.source === 'warm') { 133 + headers['X-Cache-Status'] = 'HIT-DISK'; 134 + } else { 135 + headers['X-Cache-Status'] = 'HIT-S3'; 136 + } 137 + 138 + const emoji = result.source === 'hot' ? '🔥' : result.source === 'warm' ? '💾' : '☁️'; 139 + console.log(`${emoji} ${filePath.padEnd(20)} served from ${result.source.padEnd(4)} (${(result.metadata.size / 1024).toFixed(2)} KB, access #${result.metadata.accessCount})`); 140 + 141 + return c.body(result.data as any, 200, headers); 142 + } catch (error: any) { 143 + console.error(`❌ Error serving ${filePath}:`, error.message); 144 + return c.text('500 Internal Server Error', 500); 145 + } 146 + }); 147 + 148 + /** 149 + * Admin endpoint: Cache statistics 150 + */ 151 + app.get('/admin/stats', async (c) => { 152 + const stats = await storage.getStats(); 153 + 154 + const html = ` 155 + <!DOCTYPE html> 156 + <html> 157 + <head> 158 + <title>Tiered Storage Statistics</title> 159 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 160 + <style> 161 + * { margin: 0; padding: 0; box-sizing: border-box; } 162 + body { 163 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 164 + background: #0f172a; 165 + color: #f1f5f9; 166 + padding: 2rem; 167 + line-height: 1.6; 168 + } 169 + .container { max-width: 1200px; margin: 0 auto; } 170 + h1 { 171 + font-size: 2rem; 172 + margin-bottom: 0.5rem; 173 + background: linear-gradient(135deg, #3b82f6, #8b5cf6); 174 + -webkit-background-clip: text; 175 + -webkit-text-fill-color: transparent; 176 + } 177 + .subtitle { color: #94a3b8; margin-bottom: 2rem; } 178 + .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem; margin-bottom: 2rem; } 179 + .card { 180 + background: #1e293b; 181 + border: 1px solid #334155; 182 + border-radius: 0.5rem; 183 + padding: 1.5rem; 184 + } 185 + .tier-hot { border-left: 4px solid #ef4444; } 186 + .tier-warm { border-left: 4px solid #f59e0b; } 187 + .tier-cold { border-left: 4px solid #3b82f6; } 188 + .card-title { 189 + font-size: 1.2rem; 190 + font-weight: 600; 191 + margin-bottom: 1rem; 192 + display: flex; 193 + align-items: center; 194 + gap: 0.5rem; 195 + } 196 + .stat { margin-bottom: 0.75rem; } 197 + .stat-label { color: #94a3b8; font-size: 0.9rem; } 198 + .stat-value { color: #f1f5f9; font-size: 1.5rem; font-weight: 700; } 199 + .overall { background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), rgba(139, 92, 246, 0.1)); } 200 + .refresh { 201 + display: inline-block; 202 + background: #3b82f6; 203 + color: white; 204 + padding: 0.75rem 1.5rem; 205 + border-radius: 0.5rem; 206 + text-decoration: none; 207 + font-weight: 600; 208 + margin-top: 1rem; 209 + } 210 + .refresh:hover { background: #2563eb; } 211 + code { 212 + background: #334155; 213 + padding: 0.2rem 0.5rem; 214 + border-radius: 0.25rem; 215 + font-size: 0.9em; 216 + } 217 + </style> 218 + </head> 219 + <body> 220 + <div class="container"> 221 + <h1>📊 Tiered Storage Statistics</h1> 222 + <p class="subtitle">Real-time cache performance metrics • Auto-refresh every 5 seconds</p> 223 + 224 + <div class="grid"> 225 + <div class="card tier-hot"> 226 + <div class="card-title">🔥 Hot Tier (Memory)</div> 227 + <div class="stat"> 228 + <div class="stat-label">Items</div> 229 + <div class="stat-value">${stats.hot?.items || 0}</div> 230 + </div> 231 + <div class="stat"> 232 + <div class="stat-label">Size</div> 233 + <div class="stat-value">${((stats.hot?.bytes || 0) / 1024).toFixed(2)} KB</div> 234 + </div> 235 + <div class="stat"> 236 + <div class="stat-label">Hits / Misses</div> 237 + <div class="stat-value">${stats.hot?.hits || 0} / ${stats.hot?.misses || 0}</div> 238 + </div> 239 + <div class="stat"> 240 + <div class="stat-label">Evictions</div> 241 + <div class="stat-value">${stats.hot?.evictions || 0}</div> 242 + </div> 243 + </div> 244 + 245 + <div class="card tier-warm"> 246 + <div class="card-title">💾 Warm Tier (Disk)</div> 247 + <div class="stat"> 248 + <div class="stat-label">Items</div> 249 + <div class="stat-value">${stats.warm?.items || 0}</div> 250 + </div> 251 + <div class="stat"> 252 + <div class="stat-label">Size</div> 253 + <div class="stat-value">${((stats.warm?.bytes || 0) / 1024).toFixed(2)} KB</div> 254 + </div> 255 + <div class="stat"> 256 + <div class="stat-label">Hits / Misses</div> 257 + <div class="stat-value">${stats.warm?.hits || 0} / ${stats.warm?.misses || 0}</div> 258 + </div> 259 + </div> 260 + 261 + <div class="card tier-cold"> 262 + <div class="card-title">☁️ Cold Tier (S3)</div> 263 + <div class="stat"> 264 + <div class="stat-label">Items</div> 265 + <div class="stat-value">${stats.cold.items}</div> 266 + </div> 267 + <div class="stat"> 268 + <div class="stat-label">Size</div> 269 + <div class="stat-value">${(stats.cold.bytes / 1024).toFixed(2)} KB</div> 270 + </div> 271 + </div> 272 + </div> 273 + 274 + <div class="card overall"> 275 + <div class="card-title">📈 Overall Performance</div> 276 + <div class="grid" style="grid-template-columns: repeat(3, 1fr);"> 277 + <div class="stat"> 278 + <div class="stat-label">Total Hits</div> 279 + <div class="stat-value">${stats.totalHits}</div> 280 + </div> 281 + <div class="stat"> 282 + <div class="stat-label">Total Misses</div> 283 + <div class="stat-value">${stats.totalMisses}</div> 284 + </div> 285 + <div class="stat"> 286 + <div class="stat-label">Hit Rate</div> 287 + <div class="stat-value">${(stats.hitRate * 100).toFixed(1)}%</div> 288 + </div> 289 + </div> 290 + </div> 291 + 292 + <div style="margin-top: 2rem; padding: 1rem; background: #1e293b; border-radius: 0.5rem; border: 1px solid #334155;"> 293 + <p style="margin-bottom: 0.5rem;"><strong>Try it out:</strong></p> 294 + <p>Visit <code>http://localhost:${PORT}/sites/${siteId}/${siteName}/</code> to see the site</p> 295 + <p>Watch the stats update as you browse different pages!</p> 296 + </div> 297 + </div> 298 + 299 + <script> 300 + // Auto-refresh stats every 5 seconds 301 + setTimeout(() => window.location.reload(), 5000); 302 + </script> 303 + </body> 304 + </html> 305 + `; 306 + 307 + return c.html(html); 308 + }); 309 + 310 + /** 311 + * Admin endpoint: Invalidate cache 312 + */ 313 + app.post('/admin/invalidate/:did/:siteName', async (c) => { 314 + const { did, siteName } = c.req.param(); 315 + const prefix = `${did}/${siteName}/`; 316 + const deleted = await storage.invalidate(prefix); 317 + 318 + console.log(`🗑️ Invalidated ${deleted} files for ${did}/${siteName}`); 319 + 320 + return c.json({ success: true, deleted, prefix }); 321 + }); 322 + 323 + /** 324 + * Admin endpoint: Bootstrap hot cache 325 + */ 326 + app.post('/admin/bootstrap/hot', async (c) => { 327 + const limit = parseInt(c.req.query('limit') || '100', 10); 328 + const loaded = await storage.bootstrapHot(limit); 329 + 330 + console.log(`🔥 Bootstrapped ${loaded} items into hot tier`); 331 + 332 + return c.json({ success: true, loaded, limit }); 333 + }); 334 + 335 + /** 336 + * Root redirect 337 + */ 338 + app.get('/', (c) => { 339 + return c.redirect(`/sites/${siteId}/${siteName}/`); 340 + }); 341 + 342 + /** 343 + * Health check 344 + */ 345 + app.get('/health', (c) => c.json({ status: 'ok' })); 346 + 347 + /** 348 + * Test S3 connection 349 + */ 350 + async function testS3Connection() { 351 + console.log('\n🔍 Testing S3 connection...\n'); 352 + 353 + try { 354 + // Try to get stats (which lists objects) 355 + const stats = await storage.getStats(); 356 + console.log(`✅ S3 connection successful!`); 357 + console.log(` Found ${stats.cold.items} items (${(stats.cold.bytes / 1024).toFixed(2)} KB)\n`); 358 + return true; 359 + } catch (error: any) { 360 + console.error('❌ S3 connection failed:', error.message); 361 + console.error('\nDebug Info:'); 362 + console.error(` Bucket: ${S3_BUCKET}`); 363 + console.error(` Region: ${S3_REGION}`); 364 + console.error(` Endpoint: ${S3_ENDPOINT || '(default AWS S3)'}`); 365 + console.error(` Access Key: ${AWS_ACCESS_KEY_ID?.substring(0, 8)}...`); 366 + console.error(` Force Path Style: ${S3_FORCE_PATH_STYLE}`); 367 + console.error('\nCommon issues:'); 368 + console.error(' • Check that bucket exists'); 369 + console.error(' • Verify credentials are correct'); 370 + console.error(' • Ensure endpoint URL is correct'); 371 + console.error(' • Check firewall/network access'); 372 + console.error(' • For S3-compatible services, verify region name\n'); 373 + return false; 374 + } 375 + } 376 + 377 + /** 378 + * Periodic cache clearing - demonstrates tier bootstrapping 379 + */ 380 + function startCacheClearInterval() { 381 + const CLEAR_INTERVAL_MS = 60 * 1000; // 1 minute 382 + 383 + setInterval(async () => { 384 + console.log('\n' + '═'.repeat(60)); 385 + console.log('🧹 CACHE CLEAR - Clearing hot and warm tiers...'); 386 + console.log(' (Cold tier on S3 remains intact)'); 387 + console.log('═'.repeat(60) + '\n'); 388 + 389 + try { 390 + // Clear hot tier (memory) 391 + if (storage['config'].tiers.hot) { 392 + await storage['config'].tiers.hot.clear(); 393 + console.log('✓ Hot tier (memory) cleared'); 394 + } 395 + 396 + // Clear warm tier (disk) 397 + if (storage['config'].tiers.warm) { 398 + await storage['config'].tiers.warm.clear(); 399 + console.log('✓ Warm tier (disk) cleared'); 400 + } 401 + 402 + console.log('\n💡 Next request will bootstrap from S3 (cold tier)\n'); 403 + console.log('─'.repeat(60) + '\n'); 404 + } catch (error: any) { 405 + console.error('❌ Error clearing cache:', error.message); 406 + } 407 + }, CLEAR_INTERVAL_MS); 408 + 409 + console.log(`⏰ Cache clear interval started (every ${CLEAR_INTERVAL_MS / 1000}s)\n`); 410 + } 411 + 412 + /** 413 + * Main startup 414 + */ 415 + async function main() { 416 + console.log('╔════════════════════════════════════════════════╗'); 417 + console.log('║ Tiered Storage Demo Server ║'); 418 + console.log('╚════════════════════════════════════════════════╝\n'); 419 + 420 + console.log('Configuration:'); 421 + console.log(` S3 Bucket: ${S3_BUCKET}`); 422 + console.log(` S3 Region: ${S3_REGION}`); 423 + console.log(` S3 Endpoint: ${S3_ENDPOINT || '(default AWS S3)'}`); 424 + console.log(` Force Path Style: ${S3_FORCE_PATH_STYLE}`); 425 + console.log(` Port: ${PORT}`); 426 + 427 + try { 428 + // Test S3 connection first 429 + const s3Connected = await testS3Connection(); 430 + if (!s3Connected) { 431 + process.exit(1); 432 + } 433 + 434 + // Load the example site 435 + await loadExampleSite(); 436 + 437 + // Start periodic cache clearing 438 + startCacheClearInterval(); 439 + 440 + // Start the server 441 + console.log('🚀 Starting server...\n'); 442 + 443 + const server = Bun.serve({ 444 + port: PORT, 445 + fetch: app.fetch, 446 + }); 447 + 448 + console.log('╔════════════════════════════════════════════════╗'); 449 + console.log('║ Server Running! ║'); 450 + console.log('╚════════════════════════════════════════════════╝\n'); 451 + console.log(`📍 Demo Site: http://localhost:${PORT}/sites/${siteId}/${siteName}/`); 452 + console.log(`📊 Statistics: http://localhost:${PORT}/admin/stats`); 453 + console.log(`💚 Health: http://localhost:${PORT}/health`); 454 + console.log('\n🎯 Try browsing the site and watch which tier serves each file!\n'); 455 + console.log('💡 Caches clear every 60 seconds - watch files get re-fetched from S3!\n'); 456 + if (S3_METADATA_BUCKET) { 457 + console.log(`✨ Metadata bucket: ${S3_METADATA_BUCKET} (fast updates enabled!)\n`); 458 + } else { 459 + console.log('⚠️ No metadata bucket - using legacy mode (slower updates)\n'); 460 + } 461 + console.log('Press Ctrl+C to stop\n'); 462 + console.log('─'.repeat(60)); 463 + console.log('Request Log:\n'); 464 + } catch (error: any) { 465 + console.error('\n❌ Failed to start server:', error.message); 466 + if (error.message.includes('Forbidden')) { 467 + console.error('\nS3 connection issue. Check:'); 468 + console.error(' 1. Bucket exists on S3 service'); 469 + console.error(' 2. Credentials are correct'); 470 + console.error(' 3. Permissions allow read/write'); 471 + } 472 + process.exit(1); 473 + } 474 + } 475 + 476 + main().catch(console.error);
+1040
packages/@wispplace/tiered-storage/src/TieredStorage.ts
··· 1 + import type { 2 + TieredStorageConfig, 3 + SetOptions, 4 + StorageResult, 5 + SetResult, 6 + StorageMetadata, 7 + StorageTier, 8 + AllTierStats, 9 + StorageSnapshot, 10 + StreamResult, 11 + StreamSetOptions, 12 + } from './types/index'; 13 + import { 14 + compress, 15 + decompress, 16 + createCompressStream, 17 + createDecompressStream, 18 + } from './utils/compression.js'; 19 + import { defaultSerialize, defaultDeserialize } from './utils/serialization.js'; 20 + import { calculateChecksum } from './utils/checksum.js'; 21 + import { matchGlob } from './utils/glob.js'; 22 + import { PassThrough, type Readable } from 'node:stream'; 23 + import { createHash } from 'node:crypto'; 24 + 25 + /** 26 + * Main orchestrator for tiered storage system. 27 + * 28 + * @typeParam T - The type of data being stored 29 + * 30 + * @remarks 31 + * Implements a cascading containment model: 32 + * - **Write Strategy (Cascading Down):** Write to hot → also writes to warm and cold 33 + * - **Read Strategy (Bubbling Up):** Check hot first → if miss, check warm → if miss, check cold 34 + * - **Bootstrap Strategy:** Hot can bootstrap from warm, warm can bootstrap from cold 35 + * 36 + * The cold tier is the source of truth and is required. 37 + * Hot and warm tiers are optional performance optimizations. 38 + * 39 + * @example 40 + * ```typescript 41 + * const storage = new TieredStorage({ 42 + * tiers: { 43 + * hot: new MemoryStorageTier({ maxSizeBytes: 100 * 1024 * 1024 }), // 100MB 44 + * warm: new DiskStorageTier({ directory: './cache' }), 45 + * cold: new S3StorageTier({ bucket: 'my-bucket', region: 'us-east-1' }), 46 + * }, 47 + * compression: true, 48 + * defaultTTL: 14 * 24 * 60 * 60 * 1000, // 14 days 49 + * promotionStrategy: 'lazy', 50 + * }); 51 + * 52 + * // Store data (cascades to all tiers) 53 + * await storage.set('user:123', { name: 'Alice' }); 54 + * 55 + * // Retrieve data (bubbles up from cold → warm → hot) 56 + * const user = await storage.get('user:123'); 57 + * 58 + * // Invalidate all keys with prefix 59 + * await storage.invalidate('user:'); 60 + * ``` 61 + */ 62 + export class TieredStorage<T = unknown> { 63 + private serialize: (data: unknown) => Promise<Uint8Array>; 64 + private deserialize: (data: Uint8Array) => Promise<unknown>; 65 + 66 + constructor(private config: TieredStorageConfig) { 67 + if (!config.tiers.cold) { 68 + throw new Error('Cold tier is required'); 69 + } 70 + 71 + this.serialize = config.serialization?.serialize ?? defaultSerialize; 72 + this.deserialize = config.serialization?.deserialize ?? defaultDeserialize; 73 + } 74 + 75 + /** 76 + * Retrieve data for a key. 77 + * 78 + * @param key - The key to retrieve 79 + * @returns The data, or null if not found or expired 80 + * 81 + * @remarks 82 + * Checks tiers in order: hot → warm → cold. 83 + * On cache miss, promotes data to upper tiers based on promotionStrategy. 84 + * Automatically handles decompression and deserialization. 85 + * Returns null if key doesn't exist or has expired (TTL). 86 + */ 87 + async get(key: string): Promise<T | null> { 88 + const result = await this.getWithMetadata(key); 89 + return result ? result.data : null; 90 + } 91 + 92 + /** 93 + * Retrieve data with metadata and source tier information. 94 + * 95 + * @param key - The key to retrieve 96 + * @returns The data, metadata, and source tier, or null if not found 97 + * 98 + * @remarks 99 + * Use this when you need to know: 100 + * - Which tier served the data (for observability) 101 + * - Metadata like access count, TTL, checksum 102 + * - When the data was created/last accessed 103 + */ 104 + async getWithMetadata(key: string): Promise<StorageResult<T> | null> { 105 + // 1. Check hot tier first 106 + if (this.config.tiers.hot) { 107 + const result = await this.getFromTier(this.config.tiers.hot, key); 108 + if (result) { 109 + if (this.isExpired(result.metadata)) { 110 + await this.delete(key); 111 + return null; 112 + } 113 + // Fire-and-forget access stats update (non-critical) 114 + void this.updateAccessStats(key, 'hot'); 115 + return { 116 + data: (await this.deserializeData(result.data)) as T, 117 + metadata: result.metadata, 118 + source: 'hot', 119 + }; 120 + } 121 + } 122 + 123 + // 2. Check warm tier 124 + if (this.config.tiers.warm) { 125 + const result = await this.getFromTier(this.config.tiers.warm, key); 126 + if (result) { 127 + if (this.isExpired(result.metadata)) { 128 + await this.delete(key); 129 + return null; 130 + } 131 + // Eager promotion to hot tier (awaited - guaranteed to complete) 132 + if (this.config.tiers.hot && this.config.promotionStrategy === 'eager') { 133 + await this.config.tiers.hot.set(key, result.data, result.metadata); 134 + } 135 + // Fire-and-forget access stats update (non-critical) 136 + void this.updateAccessStats(key, 'warm'); 137 + return { 138 + data: (await this.deserializeData(result.data)) as T, 139 + metadata: result.metadata, 140 + source: 'warm', 141 + }; 142 + } 143 + } 144 + 145 + // 3. Check cold tier (source of truth) 146 + const result = await this.getFromTier(this.config.tiers.cold, key); 147 + if (result) { 148 + if (this.isExpired(result.metadata)) { 149 + await this.delete(key); 150 + return null; 151 + } 152 + 153 + // Promote to warm and hot (if configured) 154 + // Eager promotion is awaited to guarantee completion 155 + if (this.config.promotionStrategy === 'eager') { 156 + const promotions: Promise<void>[] = []; 157 + if (this.config.tiers.warm) { 158 + promotions.push(this.config.tiers.warm.set(key, result.data, result.metadata)); 159 + } 160 + if (this.config.tiers.hot) { 161 + promotions.push(this.config.tiers.hot.set(key, result.data, result.metadata)); 162 + } 163 + await Promise.all(promotions); 164 + } 165 + 166 + // Fire-and-forget access stats update (non-critical) 167 + void this.updateAccessStats(key, 'cold'); 168 + return { 169 + data: (await this.deserializeData(result.data)) as T, 170 + metadata: result.metadata, 171 + source: 'cold', 172 + }; 173 + } 174 + 175 + return null; 176 + } 177 + 178 + /** 179 + * Get data and metadata from a tier using the most efficient method. 180 + * 181 + * @remarks 182 + * Uses the tier's getWithMetadata if available, otherwise falls back 183 + * to separate get() and getMetadata() calls. 184 + */ 185 + private async getFromTier( 186 + tier: StorageTier, 187 + key: string, 188 + ): Promise<{ data: Uint8Array; metadata: StorageMetadata } | null> { 189 + // Use optimized combined method if available 190 + if (tier.getWithMetadata) { 191 + return tier.getWithMetadata(key); 192 + } 193 + 194 + // Fallback: separate calls 195 + const data = await tier.get(key); 196 + if (!data) { 197 + return null; 198 + } 199 + const metadata = await tier.getMetadata(key); 200 + if (!metadata) { 201 + return null; 202 + } 203 + return { data, metadata }; 204 + } 205 + 206 + /** 207 + * Retrieve data as a readable stream with metadata. 208 + * 209 + * @param key - The key to retrieve 210 + * @returns A readable stream, metadata, and source tier, or null if not found 211 + * 212 + * @remarks 213 + * Use this for large files to avoid loading entire content into memory. 214 + * The stream must be consumed or destroyed by the caller. 215 + * 216 + * Checks tiers in order: hot → warm → cold. 217 + * On cache miss, does NOT promote data to upper tiers (streaming would 218 + * require buffering, defeating the purpose). 219 + * 220 + * Decompression is automatically handled if the data was stored with 221 + * compression enabled (metadata.compressed = true). 222 + * 223 + * @example 224 + * ```typescript 225 + * const result = await storage.getStream('large-file.mp4'); 226 + * if (result) { 227 + * result.stream.pipe(response); // Stream directly to HTTP response 228 + * } 229 + * ``` 230 + */ 231 + async getStream(key: string): Promise<StreamResult | null> { 232 + // 1. Check hot tier first 233 + if (this.config.tiers.hot?.getStream) { 234 + const result = await this.config.tiers.hot.getStream(key); 235 + if (result) { 236 + if (this.isExpired(result.metadata)) { 237 + (result.stream as Readable).destroy?.(); 238 + await this.delete(key); 239 + return null; 240 + } 241 + void this.updateAccessStats(key, 'hot'); 242 + return this.wrapStreamWithDecompression(result, 'hot'); 243 + } 244 + } 245 + 246 + // 2. Check warm tier 247 + if (this.config.tiers.warm?.getStream) { 248 + const result = await this.config.tiers.warm.getStream(key); 249 + if (result) { 250 + if (this.isExpired(result.metadata)) { 251 + (result.stream as Readable).destroy?.(); 252 + await this.delete(key); 253 + return null; 254 + } 255 + // NOTE: No promotion for streaming (would require buffering) 256 + void this.updateAccessStats(key, 'warm'); 257 + return this.wrapStreamWithDecompression(result, 'warm'); 258 + } 259 + } 260 + 261 + // 3. Check cold tier (source of truth) 262 + if (this.config.tiers.cold.getStream) { 263 + const result = await this.config.tiers.cold.getStream(key); 264 + if (result) { 265 + if (this.isExpired(result.metadata)) { 266 + (result.stream as Readable).destroy?.(); 267 + await this.delete(key); 268 + return null; 269 + } 270 + // NOTE: No promotion for streaming (would require buffering) 271 + void this.updateAccessStats(key, 'cold'); 272 + return this.wrapStreamWithDecompression(result, 'cold'); 273 + } 274 + } 275 + 276 + return null; 277 + } 278 + 279 + /** 280 + * Wrap a stream result with decompression if needed. 281 + */ 282 + private wrapStreamWithDecompression( 283 + result: { stream: NodeJS.ReadableStream; metadata: StorageMetadata }, 284 + source: 'hot' | 'warm' | 'cold', 285 + ): StreamResult { 286 + if (result.metadata.compressed) { 287 + // Pipe through decompression stream 288 + const decompressStream = createDecompressStream(); 289 + (result.stream as Readable).pipe(decompressStream); 290 + return { stream: decompressStream, metadata: result.metadata, source }; 291 + } 292 + return { ...result, source }; 293 + } 294 + 295 + /** 296 + * Store data from a readable stream. 297 + * 298 + * @param key - The key to store under 299 + * @param stream - Readable stream of data to store 300 + * @param options - Configuration including size (required), checksum, and tier options 301 + * @returns Information about what was stored and where 302 + * 303 + * @remarks 304 + * Use this for large files to avoid loading entire content into memory. 305 + * 306 + * **Important differences from set():** 307 + * - `options.size` is required (stream size cannot be determined upfront) 308 + * - Serialization is NOT applied (stream is stored as-is) 309 + * - If no checksum is provided, one will be computed during streaming 310 + * - Checksum is computed on the original (pre-compression) data 311 + * 312 + * **Compression:** 313 + * - If `config.compression` is true, the stream is compressed before storage 314 + * - Checksum is always computed on the original uncompressed data 315 + * 316 + * **Tier handling:** 317 + * - Only writes to tiers that support streaming (have setStream method) 318 + * - Hot tier is skipped by default for streaming (typically memory-based) 319 + * - Tees the stream to write to multiple tiers simultaneously 320 + * 321 + * @example 322 + * ```typescript 323 + * const fileStream = fs.createReadStream('large-file.mp4'); 324 + * const stat = fs.statSync('large-file.mp4'); 325 + * 326 + * await storage.setStream('videos/large.mp4', fileStream, { 327 + * size: stat.size, 328 + * mimeType: 'video/mp4', 329 + * }); 330 + * ``` 331 + */ 332 + async setStream( 333 + key: string, 334 + stream: NodeJS.ReadableStream, 335 + options: StreamSetOptions, 336 + ): Promise<SetResult> { 337 + const shouldCompress = this.config.compression ?? false; 338 + 339 + // Create metadata 340 + const now = new Date(); 341 + const ttl = options.ttl ?? this.config.defaultTTL; 342 + 343 + const metadata: StorageMetadata = { 344 + key, 345 + size: options.size, // Original uncompressed size 346 + createdAt: now, 347 + lastAccessed: now, 348 + accessCount: 0, 349 + compressed: shouldCompress, 350 + checksum: options.checksum ?? '', // Will be computed if not provided 351 + ...(options.mimeType && { mimeType: options.mimeType }), 352 + }; 353 + 354 + if (ttl) { 355 + metadata.ttl = new Date(now.getTime() + ttl); 356 + } 357 + 358 + if (options.metadata) { 359 + metadata.customMetadata = options.metadata; 360 + } 361 + 362 + // Determine which tiers to write to 363 + // Default: skip hot tier for streaming (typically memory-based, defeats purpose) 364 + const tierOptions: { skipTiers?: ('hot' | 'warm')[]; onlyTiers?: ('hot' | 'warm' | 'cold')[] } = {}; 365 + if (options.onlyTiers) { 366 + tierOptions.onlyTiers = options.onlyTiers; 367 + } else if (options.skipTiers) { 368 + tierOptions.skipTiers = options.skipTiers; 369 + } else { 370 + tierOptions.skipTiers = ['hot']; // Default for streaming 371 + } 372 + const allowedTiers = this.getTiersForKey(key, tierOptions); 373 + 374 + // Collect tiers that support streaming 375 + const streamingTiers: Array<{ name: 'hot' | 'warm' | 'cold'; tier: StorageTier }> = []; 376 + 377 + if (this.config.tiers.hot?.setStream && allowedTiers.includes('hot')) { 378 + streamingTiers.push({ name: 'hot', tier: this.config.tiers.hot }); 379 + } 380 + 381 + if (this.config.tiers.warm?.setStream && allowedTiers.includes('warm')) { 382 + streamingTiers.push({ name: 'warm', tier: this.config.tiers.warm }); 383 + } 384 + 385 + if (this.config.tiers.cold.setStream && allowedTiers.includes('cold')) { 386 + streamingTiers.push({ name: 'cold', tier: this.config.tiers.cold }); 387 + } 388 + 389 + const tiersWritten: ('hot' | 'warm' | 'cold')[] = []; 390 + 391 + if (streamingTiers.length === 0) { 392 + throw new Error('No tiers support streaming. Use set() for buffered writes.'); 393 + } 394 + 395 + // We always need to compute checksum on uncompressed data if not provided 396 + const needsChecksum = !options.checksum; 397 + 398 + // Create pass-through streams for each tier 399 + const passThroughs = streamingTiers.map(() => new PassThrough()); 400 + const hashStream = needsChecksum ? createHash('sha256') : null; 401 + 402 + // Set up the stream pipeline: 403 + // source -> (hash) -> (compress) -> tee to all tier streams 404 + const sourceStream = stream as Readable; 405 + 406 + // If compression is enabled, we need to: 407 + // 1. Compute hash on original data 408 + // 2. Then compress 409 + // 3. Then tee to all tiers 410 + if (shouldCompress) { 411 + const compressStream = createCompressStream(); 412 + 413 + // Hash the original uncompressed data 414 + sourceStream.on('data', (chunk: Buffer) => { 415 + if (hashStream) { 416 + hashStream.update(chunk); 417 + } 418 + }); 419 + 420 + // Pipe source through compression 421 + sourceStream.pipe(compressStream); 422 + 423 + // Tee compressed output to all tier streams 424 + compressStream.on('data', (chunk: Buffer) => { 425 + for (const pt of passThroughs) { 426 + pt.write(chunk); 427 + } 428 + }); 429 + 430 + compressStream.on('end', () => { 431 + for (const pt of passThroughs) { 432 + pt.end(); 433 + } 434 + }); 435 + 436 + compressStream.on('error', (err) => { 437 + for (const pt of passThroughs) { 438 + pt.destroy(err); 439 + } 440 + }); 441 + } else { 442 + // No compression - hash and tee directly 443 + sourceStream.on('data', (chunk: Buffer) => { 444 + for (const pt of passThroughs) { 445 + pt.write(chunk); 446 + } 447 + if (hashStream) { 448 + hashStream.update(chunk); 449 + } 450 + }); 451 + 452 + sourceStream.on('end', () => { 453 + for (const pt of passThroughs) { 454 + pt.end(); 455 + } 456 + }); 457 + 458 + sourceStream.on('error', (err) => { 459 + for (const pt of passThroughs) { 460 + pt.destroy(err); 461 + } 462 + }); 463 + } 464 + 465 + // Wait for all tier writes 466 + const writePromises = streamingTiers.map(async ({ name, tier }, index) => { 467 + await tier.setStream!(key, passThroughs[index]!, metadata); 468 + tiersWritten.push(name); 469 + }); 470 + 471 + await Promise.all(writePromises); 472 + 473 + // Update checksum in metadata if computed 474 + if (hashStream) { 475 + metadata.checksum = hashStream.digest('hex'); 476 + // Update metadata in all tiers with the computed checksum 477 + await Promise.all(streamingTiers.map(({ tier }) => tier.setMetadata(key, metadata))); 478 + } 479 + 480 + return { key, metadata, tiersWritten }; 481 + } 482 + 483 + /** 484 + * Store data with optional configuration. 485 + * 486 + * @param key - The key to store under 487 + * @param data - The data to store 488 + * @param options - Optional configuration (TTL, metadata, tier skipping) 489 + * @returns Information about what was stored and where 490 + * 491 + * @remarks 492 + * Data cascades down through tiers: 493 + * - If written to hot, also written to warm and cold 494 + * - If written to warm (hot skipped), also written to cold 495 + * - Cold is always written (source of truth) 496 + * 497 + * Use `skipTiers` to control placement. For example: 498 + * - Large files: `skipTiers: ['hot']` to avoid memory bloat 499 + * - Critical small files: Write to all tiers for fastest access 500 + * 501 + * Automatically handles serialization and optional compression. 502 + */ 503 + async set(key: string, data: T, options?: SetOptions): Promise<SetResult> { 504 + // 1. Serialize data 505 + const serialized = await this.serialize(data); 506 + 507 + // 2. Optionally compress 508 + const finalData = this.config.compression ? await compress(serialized) : serialized; 509 + 510 + // 3. Create metadata 511 + const metadata = this.createMetadata(key, finalData, options); 512 + 513 + // 4. Determine which tiers to write to 514 + const tierOptions: { skipTiers?: ('hot' | 'warm')[]; onlyTiers?: ('hot' | 'warm' | 'cold')[] } = {}; 515 + if (options?.onlyTiers) { 516 + tierOptions.onlyTiers = options.onlyTiers; 517 + } else if (options?.skipTiers) { 518 + tierOptions.skipTiers = options.skipTiers; 519 + } 520 + const allowedTiers = this.getTiersForKey(key, tierOptions); 521 + 522 + // 5. Write to tiers 523 + const tiersWritten: ('hot' | 'warm' | 'cold')[] = []; 524 + 525 + if (this.config.tiers.hot && allowedTiers.includes('hot')) { 526 + await this.config.tiers.hot.set(key, finalData, metadata); 527 + tiersWritten.push('hot'); 528 + } 529 + 530 + if (this.config.tiers.warm && allowedTiers.includes('warm')) { 531 + await this.config.tiers.warm.set(key, finalData, metadata); 532 + tiersWritten.push('warm'); 533 + } 534 + 535 + if (allowedTiers.includes('cold')) { 536 + await this.config.tiers.cold.set(key, finalData, metadata); 537 + tiersWritten.push('cold'); 538 + } 539 + 540 + return { key, metadata, tiersWritten }; 541 + } 542 + 543 + /** 544 + * Determine which tiers a key should be written to. 545 + * 546 + * @param key - The key being stored 547 + * @param options - skipTiers or onlyTiers options 548 + * @returns Array of tiers to write to 549 + * 550 + * @remarks 551 + * Priority: onlyTiers > skipTiers > placementRules > all configured tiers 552 + */ 553 + private getTiersForKey( 554 + key: string, 555 + options?: { skipTiers?: ('hot' | 'warm')[]; onlyTiers?: ('hot' | 'warm' | 'cold')[] }, 556 + ): ('hot' | 'warm' | 'cold')[] { 557 + // If explicit onlyTiers provided, use that exactly 558 + if (options?.onlyTiers && options.onlyTiers.length > 0) { 559 + return options.onlyTiers; 560 + } 561 + 562 + // If explicit skipTiers provided, use that 563 + if (options?.skipTiers && options.skipTiers.length > 0) { 564 + const allTiers: ('hot' | 'warm' | 'cold')[] = ['hot', 'warm', 'cold']; 565 + return allTiers.filter((t) => !options.skipTiers!.includes(t as 'hot' | 'warm')); 566 + } 567 + 568 + // Check placement rules 569 + if (this.config.placementRules) { 570 + for (const rule of this.config.placementRules) { 571 + if (matchGlob(rule.pattern, key)) { 572 + // Ensure cold is always included 573 + if (!rule.tiers.includes('cold')) { 574 + return [...rule.tiers, 'cold']; 575 + } 576 + return rule.tiers; 577 + } 578 + } 579 + } 580 + 581 + // Default: write to all configured tiers 582 + return ['hot', 'warm', 'cold']; 583 + } 584 + 585 + /** 586 + * Delete data from all tiers. 587 + * 588 + * @param key - The key to delete 589 + * 590 + * @remarks 591 + * Deletes from all configured tiers in parallel. 592 + * Does not throw if the key doesn't exist. 593 + */ 594 + async delete(key: string): Promise<void> { 595 + await Promise.all([ 596 + this.config.tiers.hot?.delete(key), 597 + this.config.tiers.warm?.delete(key), 598 + this.config.tiers.cold.delete(key), 599 + ]); 600 + } 601 + 602 + /** 603 + * Check if a key exists in any tier. 604 + * 605 + * @param key - The key to check 606 + * @returns true if the key exists and hasn't expired 607 + * 608 + * @remarks 609 + * Checks tiers in order: hot → warm → cold. 610 + * Returns false if key exists but has expired. 611 + */ 612 + async exists(key: string): Promise<boolean> { 613 + // Check hot first (fastest) 614 + if (this.config.tiers.hot && (await this.config.tiers.hot.exists(key))) { 615 + const metadata = await this.config.tiers.hot.getMetadata(key); 616 + if (metadata && !this.isExpired(metadata)) { 617 + return true; 618 + } 619 + } 620 + 621 + // Check warm 622 + if (this.config.tiers.warm && (await this.config.tiers.warm.exists(key))) { 623 + const metadata = await this.config.tiers.warm.getMetadata(key); 624 + if (metadata && !this.isExpired(metadata)) { 625 + return true; 626 + } 627 + } 628 + 629 + // Check cold (source of truth) 630 + if (await this.config.tiers.cold.exists(key)) { 631 + const metadata = await this.config.tiers.cold.getMetadata(key); 632 + if (metadata && !this.isExpired(metadata)) { 633 + return true; 634 + } 635 + } 636 + 637 + return false; 638 + } 639 + 640 + /** 641 + * Renew TTL for a key. 642 + * 643 + * @param key - The key to touch 644 + * @param ttlMs - Optional new TTL in milliseconds (uses default if not provided) 645 + * 646 + * @remarks 647 + * Updates the TTL and lastAccessed timestamp in all tiers. 648 + * Useful for implementing "keep alive" behavior for actively used keys. 649 + * Does nothing if no TTL is configured. 650 + */ 651 + async touch(key: string, ttlMs?: number): Promise<void> { 652 + const ttl = ttlMs ?? this.config.defaultTTL; 653 + if (!ttl) return; 654 + 655 + const newTTL = new Date(Date.now() + ttl); 656 + 657 + for (const tier of [ 658 + this.config.tiers.hot, 659 + this.config.tiers.warm, 660 + this.config.tiers.cold, 661 + ]) { 662 + if (!tier) continue; 663 + 664 + const metadata = await tier.getMetadata(key); 665 + if (metadata) { 666 + metadata.ttl = newTTL; 667 + metadata.lastAccessed = new Date(); 668 + await tier.setMetadata(key, metadata); 669 + } 670 + } 671 + } 672 + 673 + /** 674 + * Invalidate all keys matching a prefix. 675 + * 676 + * @param prefix - The prefix to match (e.g., 'user:' matches 'user:123', 'user:456') 677 + * @returns Number of keys deleted 678 + * 679 + * @remarks 680 + * Useful for bulk invalidation: 681 + * - Site invalidation: `invalidate('site:abc:')` 682 + * - User invalidation: `invalidate('user:123:')` 683 + * - Global invalidation: `invalidate('')` (deletes everything) 684 + * 685 + * Deletes from all tiers in parallel for efficiency. 686 + */ 687 + async invalidate(prefix: string): Promise<number> { 688 + const keysToDelete = new Set<string>(); 689 + 690 + // Collect all keys matching prefix from all tiers 691 + if (this.config.tiers.hot) { 692 + for await (const key of this.config.tiers.hot.listKeys(prefix)) { 693 + keysToDelete.add(key); 694 + } 695 + } 696 + 697 + if (this.config.tiers.warm) { 698 + for await (const key of this.config.tiers.warm.listKeys(prefix)) { 699 + keysToDelete.add(key); 700 + } 701 + } 702 + 703 + for await (const key of this.config.tiers.cold.listKeys(prefix)) { 704 + keysToDelete.add(key); 705 + } 706 + 707 + // Delete from all tiers in parallel 708 + const keys = Array.from(keysToDelete); 709 + 710 + await Promise.all([ 711 + this.config.tiers.hot?.deleteMany(keys), 712 + this.config.tiers.warm?.deleteMany(keys), 713 + this.config.tiers.cold.deleteMany(keys), 714 + ]); 715 + 716 + return keys.length; 717 + } 718 + 719 + /** 720 + * List all keys, optionally filtered by prefix. 721 + * 722 + * @param prefix - Optional prefix to filter keys 723 + * @returns Async iterator of keys 724 + * 725 + * @remarks 726 + * Returns keys from the cold tier (source of truth). 727 + * Memory-efficient - streams keys rather than loading all into memory. 728 + * 729 + * @example 730 + * ```typescript 731 + * for await (const key of storage.listKeys('user:')) { 732 + * console.log(key); 733 + * } 734 + * ``` 735 + */ 736 + async *listKeys(prefix?: string): AsyncIterableIterator<string> { 737 + // List from cold tier (source of truth) 738 + for await (const key of this.config.tiers.cold.listKeys(prefix)) { 739 + yield key; 740 + } 741 + } 742 + 743 + /** 744 + * Get aggregated statistics across all tiers. 745 + * 746 + * @returns Statistics including size, item count, hits, misses, hit rate 747 + * 748 + * @remarks 749 + * Useful for monitoring and capacity planning. 750 + * Hit rate is calculated as: hits / (hits + misses). 751 + */ 752 + async getStats(): Promise<AllTierStats> { 753 + const [hot, warm, cold] = await Promise.all([ 754 + this.config.tiers.hot?.getStats(), 755 + this.config.tiers.warm?.getStats(), 756 + this.config.tiers.cold.getStats(), 757 + ]); 758 + 759 + const totalHits = (hot?.hits ?? 0) + (warm?.hits ?? 0) + (cold?.hits ?? 0); 760 + const totalMisses = (hot?.misses ?? 0) + (warm?.misses ?? 0) + (cold?.misses ?? 0); 761 + const hitRate = totalHits + totalMisses > 0 ? totalHits / (totalHits + totalMisses) : 0; 762 + 763 + return { 764 + ...(hot && { hot }), 765 + ...(warm && { warm }), 766 + cold, 767 + totalHits, 768 + totalMisses, 769 + hitRate, 770 + }; 771 + } 772 + 773 + /** 774 + * Clear all data from all tiers. 775 + * 776 + * @remarks 777 + * Use with extreme caution! This will delete all data in the entire storage system. 778 + * Cannot be undone. 779 + */ 780 + async clear(): Promise<void> { 781 + await Promise.all([ 782 + this.config.tiers.hot?.clear(), 783 + this.config.tiers.warm?.clear(), 784 + this.config.tiers.cold.clear(), 785 + ]); 786 + } 787 + 788 + /** 789 + * Clear a specific tier. 790 + * 791 + * @param tier - Which tier to clear 792 + * 793 + * @remarks 794 + * Useful for: 795 + * - Clearing hot tier to test warm/cold performance 796 + * - Clearing warm tier to force rebuilding from cold 797 + * - Clearing cold tier to start fresh (⚠️ loses source of truth!) 798 + */ 799 + async clearTier(tier: 'hot' | 'warm' | 'cold'): Promise<void> { 800 + switch (tier) { 801 + case 'hot': 802 + await this.config.tiers.hot?.clear(); 803 + break; 804 + case 'warm': 805 + await this.config.tiers.warm?.clear(); 806 + break; 807 + case 'cold': 808 + await this.config.tiers.cold.clear(); 809 + break; 810 + } 811 + } 812 + 813 + /** 814 + * Export metadata snapshot for backup or migration. 815 + * 816 + * @returns Snapshot containing all keys, metadata, and statistics 817 + * 818 + * @remarks 819 + * The snapshot includes metadata but not the actual data (data remains in tiers). 820 + * Useful for: 821 + * - Backup and restore 822 + * - Migration between storage systems 823 + * - Auditing and compliance 824 + */ 825 + async export(): Promise<StorageSnapshot> { 826 + const keys: string[] = []; 827 + const metadata: Record<string, StorageMetadata> = {}; 828 + 829 + // Export from cold tier (source of truth) 830 + for await (const key of this.config.tiers.cold.listKeys()) { 831 + keys.push(key); 832 + const meta = await this.config.tiers.cold.getMetadata(key); 833 + if (meta) { 834 + metadata[key] = meta; 835 + } 836 + } 837 + 838 + const stats = await this.getStats(); 839 + 840 + return { 841 + version: 1, 842 + exportedAt: new Date(), 843 + keys, 844 + metadata, 845 + stats, 846 + }; 847 + } 848 + 849 + /** 850 + * Import metadata snapshot. 851 + * 852 + * @param snapshot - Snapshot to import 853 + * 854 + * @remarks 855 + * Validates version compatibility before importing. 856 + * Only imports metadata - assumes data already exists in cold tier. 857 + */ 858 + async import(snapshot: StorageSnapshot): Promise<void> { 859 + if (snapshot.version !== 1) { 860 + throw new Error(`Unsupported snapshot version: ${snapshot.version}`); 861 + } 862 + 863 + // Import metadata into all configured tiers 864 + for (const key of snapshot.keys) { 865 + const metadata = snapshot.metadata[key]; 866 + if (!metadata) continue; 867 + 868 + if (this.config.tiers.hot) { 869 + await this.config.tiers.hot.setMetadata(key, metadata); 870 + } 871 + 872 + if (this.config.tiers.warm) { 873 + await this.config.tiers.warm.setMetadata(key, metadata); 874 + } 875 + 876 + await this.config.tiers.cold.setMetadata(key, metadata); 877 + } 878 + } 879 + 880 + /** 881 + * Bootstrap hot tier from warm tier. 882 + * 883 + * @param limit - Optional limit on number of items to load 884 + * @returns Number of items loaded 885 + * 886 + * @remarks 887 + * Loads the most frequently accessed items from warm into hot. 888 + * Useful for warming up the cache after a restart. 889 + * Items are sorted by: accessCount * lastAccessed timestamp (higher is better). 890 + */ 891 + async bootstrapHot(limit?: number): Promise<number> { 892 + if (!this.config.tiers.hot || !this.config.tiers.warm) { 893 + return 0; 894 + } 895 + 896 + let loaded = 0; 897 + const keyMetadata: Array<[string, StorageMetadata]> = []; 898 + 899 + // Load metadata for all keys 900 + for await (const key of this.config.tiers.warm.listKeys()) { 901 + const metadata = await this.config.tiers.warm.getMetadata(key); 902 + if (metadata) { 903 + keyMetadata.push([key, metadata]); 904 + } 905 + } 906 + 907 + // Sort by access count * recency (simple scoring) 908 + keyMetadata.sort((a, b) => { 909 + const scoreA = a[1].accessCount * a[1].lastAccessed.getTime(); 910 + const scoreB = b[1].accessCount * b[1].lastAccessed.getTime(); 911 + return scoreB - scoreA; 912 + }); 913 + 914 + // Load top N keys into hot tier 915 + const keysToLoad = limit ? keyMetadata.slice(0, limit) : keyMetadata; 916 + 917 + for (const [key, metadata] of keysToLoad) { 918 + const data = await this.config.tiers.warm.get(key); 919 + if (data) { 920 + await this.config.tiers.hot.set(key, data, metadata); 921 + loaded++; 922 + } 923 + } 924 + 925 + return loaded; 926 + } 927 + 928 + /** 929 + * Bootstrap warm tier from cold tier. 930 + * 931 + * @param options - Optional limit and date filter 932 + * @returns Number of items loaded 933 + * 934 + * @remarks 935 + * Loads recent items from cold into warm. 936 + * Useful for: 937 + * - Initial cache population 938 + * - Recovering from warm tier failure 939 + * - Migrating to a new warm tier implementation 940 + */ 941 + async bootstrapWarm(options?: { limit?: number; sinceDate?: Date }): Promise<number> { 942 + if (!this.config.tiers.warm) { 943 + return 0; 944 + } 945 + 946 + let loaded = 0; 947 + 948 + for await (const key of this.config.tiers.cold.listKeys()) { 949 + const metadata = await this.config.tiers.cold.getMetadata(key); 950 + if (!metadata) continue; 951 + 952 + // Skip if too old 953 + if (options?.sinceDate && metadata.lastAccessed < options.sinceDate) { 954 + continue; 955 + } 956 + 957 + const data = await this.config.tiers.cold.get(key); 958 + if (data) { 959 + await this.config.tiers.warm.set(key, data, metadata); 960 + loaded++; 961 + 962 + if (options?.limit && loaded >= options.limit) { 963 + break; 964 + } 965 + } 966 + } 967 + 968 + return loaded; 969 + } 970 + 971 + /** 972 + * Check if data has expired based on TTL. 973 + */ 974 + private isExpired(metadata: StorageMetadata): boolean { 975 + if (!metadata.ttl) return false; 976 + return Date.now() > metadata.ttl.getTime(); 977 + } 978 + 979 + /** 980 + * Update access statistics for a key. 981 + */ 982 + private async updateAccessStats(key: string, tier: 'hot' | 'warm' | 'cold'): Promise<void> { 983 + const tierObj = 984 + tier === 'hot' 985 + ? this.config.tiers.hot 986 + : tier === 'warm' 987 + ? this.config.tiers.warm 988 + : this.config.tiers.cold; 989 + 990 + if (!tierObj) return; 991 + 992 + const metadata = await tierObj.getMetadata(key); 993 + if (metadata) { 994 + metadata.lastAccessed = new Date(); 995 + metadata.accessCount++; 996 + await tierObj.setMetadata(key, metadata); 997 + } 998 + } 999 + 1000 + /** 1001 + * Create metadata for new data. 1002 + */ 1003 + private createMetadata(key: string, data: Uint8Array, options?: SetOptions): StorageMetadata { 1004 + const now = new Date(); 1005 + const ttl = options?.ttl ?? this.config.defaultTTL; 1006 + 1007 + const metadata: StorageMetadata = { 1008 + key, 1009 + size: data.byteLength, 1010 + createdAt: now, 1011 + lastAccessed: now, 1012 + accessCount: 0, 1013 + compressed: this.config.compression ?? false, 1014 + checksum: calculateChecksum(data), 1015 + }; 1016 + 1017 + if (ttl) { 1018 + metadata.ttl = new Date(now.getTime() + ttl); 1019 + } 1020 + 1021 + if (options?.metadata) { 1022 + metadata.customMetadata = options.metadata; 1023 + } 1024 + 1025 + return metadata; 1026 + } 1027 + 1028 + /** 1029 + * Deserialize data, handling compression automatically. 1030 + */ 1031 + private async deserializeData(data: Uint8Array): Promise<unknown> { 1032 + // Decompress if needed (check for gzip magic bytes) 1033 + const finalData = 1034 + this.config.compression && data[0] === 0x1f && data[1] === 0x8b 1035 + ? await decompress(data) 1036 + : data; 1037 + 1038 + return this.deserialize(finalData); 1039 + } 1040 + }
+50
packages/@wispplace/tiered-storage/src/index.ts
··· 1 + /** 2 + * Tiered Storage Library 3 + * 4 + * A lightweight, pluggable tiered storage library that orchestrates caching across 5 + * hot (memory), warm (disk/database), and cold (S3/object storage) tiers. 6 + * 7 + * @packageDocumentation 8 + */ 9 + 10 + // Main class 11 + export { TieredStorage } from './TieredStorage.js'; 12 + 13 + // Built-in tier implementations 14 + export { MemoryStorageTier, type MemoryStorageTierConfig } from './tiers/MemoryStorageTier.js'; 15 + export { 16 + DiskStorageTier, 17 + type DiskStorageTierConfig, 18 + type EvictionPolicy, 19 + } from './tiers/DiskStorageTier.js'; 20 + export { S3StorageTier, type S3StorageTierConfig } from './tiers/S3StorageTier.js'; 21 + 22 + // Types 23 + export type { 24 + StorageTier, 25 + StorageMetadata, 26 + TierStats, 27 + TierGetResult, 28 + TierStreamResult, 29 + AllTierStats, 30 + TieredStorageConfig, 31 + PlacementRule, 32 + SetOptions, 33 + StreamSetOptions, 34 + StorageResult, 35 + StreamResult, 36 + SetResult, 37 + StorageSnapshot, 38 + } from './types/index.js'; 39 + 40 + // Utilities 41 + export { 42 + compress, 43 + decompress, 44 + isGzipped, 45 + createCompressStream, 46 + createDecompressStream, 47 + } from './utils/compression.js'; 48 + export { defaultSerialize, defaultDeserialize } from './utils/serialization.js'; 49 + export { calculateChecksum, verifyChecksum } from './utils/checksum.js'; 50 + export { encodeKey, decodeKey } from './utils/path-encoding.js';
+586
packages/@wispplace/tiered-storage/src/tiers/DiskStorageTier.ts
··· 1 + import { readFile, writeFile, unlink, readdir, stat, mkdir, rm, rename } from 'node:fs/promises'; 2 + import { existsSync, createReadStream, createWriteStream } from 'node:fs'; 3 + import { join, dirname } from 'node:path'; 4 + import { pipeline } from 'node:stream/promises'; 5 + import type { 6 + StorageTier, 7 + StorageMetadata, 8 + TierStats, 9 + TierGetResult, 10 + TierStreamResult, 11 + } from '../types/index.js'; 12 + import { encodeKey, decodeKey } from '../utils/path-encoding.js'; 13 + 14 + /** 15 + * Eviction policy for disk tier when size limit is reached. 16 + */ 17 + export type EvictionPolicy = 'lru' | 'fifo' | 'size'; 18 + 19 + /** 20 + * Configuration for DiskStorageTier. 21 + */ 22 + export interface DiskStorageTierConfig { 23 + /** 24 + * Directory path where files will be stored. 25 + * 26 + * @remarks 27 + * Created automatically if it doesn't exist. 28 + * Files are stored as: `{directory}/{encoded-key}` 29 + * Metadata is stored as: `{directory}/{encoded-key}.meta` 30 + */ 31 + directory: string; 32 + 33 + /** 34 + * Optional maximum size in bytes. 35 + * 36 + * @remarks 37 + * When this limit is reached, files are evicted according to the eviction policy. 38 + * If not set, no size limit is enforced (grows unbounded). 39 + */ 40 + maxSizeBytes?: number; 41 + 42 + /** 43 + * Eviction policy when maxSizeBytes is reached. 44 + * 45 + * @defaultValue 'lru' 46 + * 47 + * @remarks 48 + * - 'lru': Evict least-recently-accessed files (based on metadata.lastAccessed) 49 + * - 'fifo': Evict oldest files (based on metadata.createdAt) 50 + * - 'size': Evict largest files first 51 + */ 52 + evictionPolicy?: EvictionPolicy; 53 + 54 + /** 55 + * Whether to encode colons in keys as %3A. 56 + * 57 + * @defaultValue true on Windows, false on Unix/macOS 58 + * 59 + * @remarks 60 + * Colons are invalid in Windows filenames but allowed on Unix. 61 + * Set to false to preserve colons for human-readable paths on Unix systems. 62 + * Set to true on Windows or for cross-platform compatibility. 63 + * 64 + * @example 65 + * ```typescript 66 + * // Unix with readable paths 67 + * new DiskStorageTier({ directory: './cache', encodeColons: false }) 68 + * // Result: cache/did:plc:abc123/site/index.html 69 + * 70 + * // Windows or cross-platform 71 + * new DiskStorageTier({ directory: './cache', encodeColons: true }) 72 + * // Result: cache/did%3Aplc%3Aabc123/site/index.html 73 + * ``` 74 + */ 75 + encodeColons?: boolean; 76 + } 77 + 78 + /** 79 + * Filesystem-based storage tier. 80 + * 81 + * @remarks 82 + * - Stores data files and `.meta` JSON files side-by-side 83 + * - Keys are encoded to be filesystem-safe 84 + * - Human-readable file structure for debugging 85 + * - Optional size-based eviction with configurable policy 86 + * - Zero external dependencies (uses Node.js fs APIs) 87 + * 88 + * File structure: 89 + * ``` 90 + * cache/ 91 + * ├── user%3A123/ 92 + * │ ├── profile # Data file (encoded key) 93 + * │ └── profile.meta # Metadata JSON 94 + * └── did%3Aplc%3Aabc/ 95 + * └── site/ 96 + * ├── index.html 97 + * └── index.html.meta 98 + * ``` 99 + * 100 + * @example 101 + * ```typescript 102 + * const tier = new DiskStorageTier({ 103 + * directory: './cache', 104 + * maxSizeBytes: 10 * 1024 * 1024 * 1024, // 10GB 105 + * evictionPolicy: 'lru', 106 + * }); 107 + * 108 + * await tier.set('key', data, metadata); 109 + * const retrieved = await tier.get('key'); 110 + * ``` 111 + */ 112 + export class DiskStorageTier implements StorageTier { 113 + private metadataIndex = new Map< 114 + string, 115 + { size: number; createdAt: Date; lastAccessed: Date } 116 + >(); 117 + private currentSize = 0; 118 + private readonly encodeColons: boolean; 119 + 120 + constructor(private config: DiskStorageTierConfig) { 121 + if (!config.directory) { 122 + throw new Error('directory is required'); 123 + } 124 + if (config.maxSizeBytes !== undefined && config.maxSizeBytes <= 0) { 125 + throw new Error('maxSizeBytes must be positive'); 126 + } 127 + 128 + // Default: encode colons on Windows, preserve on Unix/macOS 129 + const platform = process.platform; 130 + this.encodeColons = config.encodeColons ?? platform === 'win32'; 131 + 132 + void this.ensureDirectory(); 133 + void this.rebuildIndex(); 134 + } 135 + 136 + private async rebuildIndex(): Promise<void> { 137 + if (!existsSync(this.config.directory)) { 138 + return; 139 + } 140 + 141 + await this.rebuildIndexRecursive(this.config.directory); 142 + } 143 + 144 + /** 145 + * Recursively rebuild index from a directory and its subdirectories. 146 + */ 147 + private async rebuildIndexRecursive(dir: string): Promise<void> { 148 + const entries = await readdir(dir, { withFileTypes: true }); 149 + 150 + for (const entry of entries) { 151 + const fullPath = join(dir, entry.name); 152 + 153 + if (entry.isDirectory()) { 154 + await this.rebuildIndexRecursive(fullPath); 155 + } else if (!entry.name.endsWith('.meta')) { 156 + try { 157 + const metaPath = `${fullPath}.meta`; 158 + const metaContent = await readFile(metaPath, 'utf-8'); 159 + const metadata = JSON.parse(metaContent) as StorageMetadata; 160 + const fileStats = await stat(fullPath); 161 + 162 + this.metadataIndex.set(metadata.key, { 163 + size: fileStats.size, 164 + createdAt: new Date(metadata.createdAt), 165 + lastAccessed: new Date(metadata.lastAccessed), 166 + }); 167 + 168 + this.currentSize += fileStats.size; 169 + } catch { 170 + continue; 171 + } 172 + } 173 + } 174 + } 175 + 176 + async get(key: string): Promise<Uint8Array | null> { 177 + const filePath = this.getFilePath(key); 178 + 179 + try { 180 + const data = await readFile(filePath); 181 + return new Uint8Array(data); 182 + } catch (error) { 183 + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 184 + return null; 185 + } 186 + throw error; 187 + } 188 + } 189 + 190 + /** 191 + * Retrieve data and metadata together in a single operation. 192 + * 193 + * @param key - The key to retrieve 194 + * @returns The data and metadata, or null if not found 195 + * 196 + * @remarks 197 + * Reads data and metadata files in parallel for better performance. 198 + */ 199 + async getWithMetadata(key: string): Promise<TierGetResult | null> { 200 + const filePath = this.getFilePath(key); 201 + const metaPath = this.getMetaPath(key); 202 + 203 + try { 204 + // Read data and metadata in parallel 205 + const [dataBuffer, metaContent] = await Promise.all([ 206 + readFile(filePath), 207 + readFile(metaPath, 'utf-8'), 208 + ]); 209 + 210 + const metadata = JSON.parse(metaContent) as StorageMetadata; 211 + 212 + // Convert date strings back to Date objects 213 + metadata.createdAt = new Date(metadata.createdAt); 214 + metadata.lastAccessed = new Date(metadata.lastAccessed); 215 + if (metadata.ttl) { 216 + metadata.ttl = new Date(metadata.ttl); 217 + } 218 + 219 + return { data: new Uint8Array(dataBuffer), metadata }; 220 + } catch (error) { 221 + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 222 + return null; 223 + } 224 + throw error; 225 + } 226 + } 227 + 228 + /** 229 + * Retrieve data as a readable stream with metadata. 230 + * 231 + * @param key - The key to retrieve 232 + * @returns A readable stream and metadata, or null if not found 233 + * 234 + * @remarks 235 + * Use this for large files to avoid loading entire content into memory. 236 + * The stream must be consumed or destroyed by the caller. 237 + */ 238 + async getStream(key: string): Promise<TierStreamResult | null> { 239 + const filePath = this.getFilePath(key); 240 + const metaPath = this.getMetaPath(key); 241 + 242 + try { 243 + // Read metadata first to verify file exists 244 + const metaContent = await readFile(metaPath, 'utf-8'); 245 + const metadata = JSON.parse(metaContent) as StorageMetadata; 246 + 247 + // Convert date strings back to Date objects 248 + metadata.createdAt = new Date(metadata.createdAt); 249 + metadata.lastAccessed = new Date(metadata.lastAccessed); 250 + if (metadata.ttl) { 251 + metadata.ttl = new Date(metadata.ttl); 252 + } 253 + 254 + // Create stream - will throw if file doesn't exist 255 + const stream = createReadStream(filePath); 256 + 257 + return { stream, metadata }; 258 + } catch (error) { 259 + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 260 + return null; 261 + } 262 + throw error; 263 + } 264 + } 265 + 266 + /** 267 + * Store data from a readable stream. 268 + * 269 + * @param key - The key to store under 270 + * @param stream - Readable stream of data to store 271 + * @param metadata - Metadata to store alongside the data 272 + * 273 + * @remarks 274 + * Use this for large files to avoid loading entire content into memory. 275 + * The stream will be fully consumed by this operation. 276 + */ 277 + async setStream( 278 + key: string, 279 + stream: NodeJS.ReadableStream, 280 + metadata: StorageMetadata, 281 + ): Promise<void> { 282 + const filePath = this.getFilePath(key); 283 + const metaPath = this.getMetaPath(key); 284 + 285 + const dir = dirname(filePath); 286 + if (!existsSync(dir)) { 287 + await mkdir(dir, { recursive: true }); 288 + } 289 + 290 + const existingEntry = this.metadataIndex.get(key); 291 + if (existingEntry) { 292 + this.currentSize -= existingEntry.size; 293 + } 294 + 295 + if (this.config.maxSizeBytes) { 296 + await this.evictIfNeeded(metadata.size); 297 + } 298 + 299 + // Write metadata first (atomic via temp file) 300 + const tempMetaPath = `${metaPath}.tmp`; 301 + await writeFile(tempMetaPath, JSON.stringify(metadata, null, 2)); 302 + 303 + // Stream data to file 304 + const writeStream = createWriteStream(filePath); 305 + await pipeline(stream, writeStream); 306 + 307 + // Commit metadata 308 + await rename(tempMetaPath, metaPath); 309 + 310 + this.metadataIndex.set(key, { 311 + size: metadata.size, 312 + createdAt: metadata.createdAt, 313 + lastAccessed: metadata.lastAccessed, 314 + }); 315 + this.currentSize += metadata.size; 316 + } 317 + 318 + async set(key: string, data: Uint8Array, metadata: StorageMetadata): Promise<void> { 319 + const filePath = this.getFilePath(key); 320 + const metaPath = this.getMetaPath(key); 321 + 322 + const dir = dirname(filePath); 323 + if (!existsSync(dir)) { 324 + await mkdir(dir, { recursive: true }); 325 + } 326 + 327 + const existingEntry = this.metadataIndex.get(key); 328 + if (existingEntry) { 329 + this.currentSize -= existingEntry.size; 330 + } 331 + 332 + if (this.config.maxSizeBytes) { 333 + await this.evictIfNeeded(data.byteLength); 334 + } 335 + 336 + const tempMetaPath = `${metaPath}.tmp`; 337 + await writeFile(tempMetaPath, JSON.stringify(metadata, null, 2)); 338 + await writeFile(filePath, data); 339 + await rename(tempMetaPath, metaPath); 340 + 341 + this.metadataIndex.set(key, { 342 + size: data.byteLength, 343 + createdAt: metadata.createdAt, 344 + lastAccessed: metadata.lastAccessed, 345 + }); 346 + this.currentSize += data.byteLength; 347 + } 348 + 349 + async delete(key: string): Promise<void> { 350 + const filePath = this.getFilePath(key); 351 + const metaPath = this.getMetaPath(key); 352 + 353 + const entry = this.metadataIndex.get(key); 354 + if (entry) { 355 + this.currentSize -= entry.size; 356 + this.metadataIndex.delete(key); 357 + } 358 + 359 + await Promise.all([unlink(filePath).catch(() => {}), unlink(metaPath).catch(() => {})]); 360 + 361 + // Clean up empty parent directories 362 + await this.cleanupEmptyDirectories(dirname(filePath)); 363 + } 364 + 365 + async exists(key: string): Promise<boolean> { 366 + const filePath = this.getFilePath(key); 367 + return existsSync(filePath); 368 + } 369 + 370 + async *listKeys(prefix?: string): AsyncIterableIterator<string> { 371 + if (!existsSync(this.config.directory)) { 372 + return; 373 + } 374 + 375 + // Recursively list all files in directory tree 376 + for await (const key of this.listKeysRecursive(this.config.directory, prefix)) { 377 + yield key; 378 + } 379 + } 380 + 381 + /** 382 + * Recursively list keys from a directory and its subdirectories. 383 + */ 384 + private async *listKeysRecursive(dir: string, prefix?: string): AsyncIterableIterator<string> { 385 + const entries = await readdir(dir, { withFileTypes: true }); 386 + 387 + for (const entry of entries) { 388 + const fullPath = join(dir, entry.name); 389 + 390 + if (entry.isDirectory()) { 391 + // Recurse into subdirectory 392 + for await (const key of this.listKeysRecursive(fullPath, prefix)) { 393 + yield key; 394 + } 395 + } else if (!entry.name.endsWith('.meta')) { 396 + // Data file - read metadata to get original key 397 + const metaPath = `${fullPath}.meta`; 398 + try { 399 + const metaContent = await readFile(metaPath, 'utf-8'); 400 + const metadata = JSON.parse(metaContent) as StorageMetadata; 401 + const originalKey = metadata.key; 402 + 403 + if (!prefix || originalKey.startsWith(prefix)) { 404 + yield originalKey; 405 + } 406 + } catch { 407 + // If metadata is missing or invalid, skip this file 408 + continue; 409 + } 410 + } 411 + } 412 + } 413 + 414 + async deleteMany(keys: string[]): Promise<void> { 415 + await Promise.all(keys.map((key) => this.delete(key))); 416 + } 417 + 418 + async getMetadata(key: string): Promise<StorageMetadata | null> { 419 + const metaPath = this.getMetaPath(key); 420 + 421 + try { 422 + const content = await readFile(metaPath, 'utf-8'); 423 + if (!content.trim()) { 424 + return null; 425 + } 426 + const metadata = JSON.parse(content) as StorageMetadata; 427 + 428 + // Convert date strings back to Date objects 429 + metadata.createdAt = new Date(metadata.createdAt); 430 + metadata.lastAccessed = new Date(metadata.lastAccessed); 431 + if (metadata.ttl) { 432 + metadata.ttl = new Date(metadata.ttl); 433 + } 434 + 435 + return metadata; 436 + } catch (error) { 437 + const code = (error as NodeJS.ErrnoException).code; 438 + if (code === 'ENOENT') { 439 + return null; 440 + } 441 + if (error instanceof SyntaxError) { 442 + return null; 443 + } 444 + throw error; 445 + } 446 + } 447 + 448 + async setMetadata(key: string, metadata: StorageMetadata): Promise<void> { 449 + const metaPath = this.getMetaPath(key); 450 + 451 + // Ensure parent directory exists 452 + const dir = dirname(metaPath); 453 + if (!existsSync(dir)) { 454 + await mkdir(dir, { recursive: true }); 455 + } 456 + 457 + await writeFile(metaPath, JSON.stringify(metadata, null, 2)); 458 + } 459 + 460 + async getStats(): Promise<TierStats> { 461 + if (!existsSync(this.config.directory)) { 462 + return { bytes: 0, items: 0 }; 463 + } 464 + 465 + return this.getStatsRecursive(this.config.directory); 466 + } 467 + 468 + /** 469 + * Recursively collect stats from a directory and its subdirectories. 470 + */ 471 + private async getStatsRecursive(dir: string): Promise<TierStats> { 472 + let bytes = 0; 473 + let items = 0; 474 + 475 + const entries = await readdir(dir, { withFileTypes: true }); 476 + 477 + for (const entry of entries) { 478 + const fullPath = join(dir, entry.name); 479 + 480 + if (entry.isDirectory()) { 481 + const subStats = await this.getStatsRecursive(fullPath); 482 + bytes += subStats.bytes; 483 + items += subStats.items; 484 + } else if (!entry.name.endsWith('.meta')) { 485 + const fileStats = await stat(fullPath); 486 + bytes += fileStats.size; 487 + items++; 488 + } 489 + } 490 + 491 + return { bytes, items }; 492 + } 493 + 494 + async clear(): Promise<void> { 495 + if (existsSync(this.config.directory)) { 496 + await rm(this.config.directory, { recursive: true, force: true }); 497 + await this.ensureDirectory(); 498 + this.metadataIndex.clear(); 499 + this.currentSize = 0; 500 + } 501 + } 502 + 503 + /** 504 + * Clean up empty parent directories after file deletion. 505 + * 506 + * @param dirPath - Directory path to start cleanup from 507 + * 508 + * @remarks 509 + * Recursively removes empty directories up to (but not including) the base directory. 510 + * This prevents directory bloat when files with nested paths are deleted. 511 + */ 512 + private async cleanupEmptyDirectories(dirPath: string): Promise<void> { 513 + // Don't remove the base directory 514 + if (dirPath === this.config.directory || !dirPath.startsWith(this.config.directory)) { 515 + return; 516 + } 517 + 518 + try { 519 + const entries = await readdir(dirPath); 520 + // If directory is empty, remove it and recurse to parent 521 + if (entries.length === 0) { 522 + await rm(dirPath, { recursive: false }); 523 + await this.cleanupEmptyDirectories(dirname(dirPath)); 524 + } 525 + } catch { 526 + // Directory doesn't exist or can't be read - that's fine 527 + return; 528 + } 529 + } 530 + 531 + /** 532 + * Get the filesystem path for a key's data file. 533 + */ 534 + private getFilePath(key: string): string { 535 + const encoded = encodeKey(key, this.encodeColons); 536 + return join(this.config.directory, encoded); 537 + } 538 + 539 + /** 540 + * Get the filesystem path for a key's metadata file. 541 + */ 542 + private getMetaPath(key: string): string { 543 + return `${this.getFilePath(key)}.meta`; 544 + } 545 + 546 + private async ensureDirectory(): Promise<void> { 547 + await mkdir(this.config.directory, { recursive: true }).catch(() => {}); 548 + } 549 + 550 + private async evictIfNeeded(incomingSize: number): Promise<void> { 551 + if (!this.config.maxSizeBytes) { 552 + return; 553 + } 554 + 555 + if (this.currentSize + incomingSize <= this.config.maxSizeBytes) { 556 + return; 557 + } 558 + 559 + const entries = Array.from(this.metadataIndex.entries()).map(([key, info]) => ({ 560 + key, 561 + ...info, 562 + })); 563 + 564 + const policy = this.config.evictionPolicy ?? 'lru'; 565 + entries.sort((a, b) => { 566 + switch (policy) { 567 + case 'lru': 568 + return a.lastAccessed.getTime() - b.lastAccessed.getTime(); 569 + case 'fifo': 570 + return a.createdAt.getTime() - b.createdAt.getTime(); 571 + case 'size': 572 + return b.size - a.size; 573 + default: 574 + return 0; 575 + } 576 + }); 577 + 578 + for (const entry of entries) { 579 + if (this.currentSize + incomingSize <= this.config.maxSizeBytes) { 580 + break; 581 + } 582 + 583 + await this.delete(entry.key); 584 + } 585 + } 586 + }
+286
packages/@wispplace/tiered-storage/src/tiers/MemoryStorageTier.ts
··· 1 + import { lru, type LRU } from 'tiny-lru'; 2 + import { Readable } from 'node:stream'; 3 + import type { 4 + StorageTier, 5 + StorageMetadata, 6 + TierStats, 7 + TierGetResult, 8 + TierStreamResult, 9 + } from '../types/index.js'; 10 + 11 + interface CacheEntry { 12 + data: Uint8Array; 13 + metadata: StorageMetadata; 14 + size: number; 15 + } 16 + 17 + /** 18 + * Configuration for MemoryStorageTier. 19 + */ 20 + export interface MemoryStorageTierConfig { 21 + /** 22 + * Maximum total size in bytes. 23 + * 24 + * @remarks 25 + * When this limit is reached, least-recently-used entries are evicted. 26 + */ 27 + maxSizeBytes: number; 28 + 29 + /** 30 + * Maximum number of items. 31 + * 32 + * @remarks 33 + * When this limit is reached, least-recently-used entries are evicted. 34 + * Useful for limiting memory usage when items have variable sizes. 35 + */ 36 + maxItems?: number; 37 + } 38 + 39 + /** 40 + * In-memory storage tier using TinyLRU for LRU eviction. 41 + * 42 + * @remarks 43 + * - Uses the battle-tested TinyLRU library for efficient LRU caching 44 + * - Automatically evicts least-recently-used entries when limits are reached 45 + * - Not distributed - single process only 46 + * - Data is lost on process restart (use warm/cold tiers for persistence) 47 + * - Implements both size-based and count-based eviction 48 + * 49 + * @example 50 + * ```typescript 51 + * const tier = new MemoryStorageTier({ 52 + * maxSizeBytes: 100 * 1024 * 1024, // 100MB 53 + * maxItems: 1000, 54 + * }); 55 + * 56 + * await tier.set('key', data, metadata); 57 + * const retrieved = await tier.get('key'); 58 + * ``` 59 + */ 60 + export class MemoryStorageTier implements StorageTier { 61 + private cache: LRU<CacheEntry>; 62 + private currentSize = 0; 63 + private stats = { 64 + hits: 0, 65 + misses: 0, 66 + evictions: 0, 67 + }; 68 + 69 + constructor(private config: MemoryStorageTierConfig) { 70 + if (config.maxSizeBytes <= 0) { 71 + throw new Error('maxSizeBytes must be positive'); 72 + } 73 + if (config.maxItems !== undefined && config.maxItems <= 0) { 74 + throw new Error('maxItems must be positive'); 75 + } 76 + 77 + // Initialize TinyLRU with max items (we'll handle size limits separately) 78 + const maxItems = config.maxItems ?? 10000; // Default to 10k items if not specified 79 + this.cache = lru<CacheEntry>(maxItems); 80 + } 81 + 82 + async get(key: string): Promise<Uint8Array | null> { 83 + const entry = this.cache.get(key); 84 + 85 + if (!entry) { 86 + this.stats.misses++; 87 + return null; 88 + } 89 + 90 + this.stats.hits++; 91 + return entry.data; 92 + } 93 + 94 + /** 95 + * Retrieve data and metadata together in a single cache lookup. 96 + * 97 + * @param key - The key to retrieve 98 + * @returns The data and metadata, or null if not found 99 + */ 100 + async getWithMetadata(key: string): Promise<TierGetResult | null> { 101 + const entry = this.cache.get(key); 102 + 103 + if (!entry) { 104 + this.stats.misses++; 105 + return null; 106 + } 107 + 108 + this.stats.hits++; 109 + return { data: entry.data, metadata: entry.metadata }; 110 + } 111 + 112 + /** 113 + * Retrieve data as a readable stream with metadata. 114 + * 115 + * @param key - The key to retrieve 116 + * @returns A readable stream and metadata, or null if not found 117 + * 118 + * @remarks 119 + * Creates a readable stream from the in-memory data. 120 + * Note that for memory tier, data is already in memory, so this 121 + * provides API consistency rather than memory savings. 122 + */ 123 + async getStream(key: string): Promise<TierStreamResult | null> { 124 + const entry = this.cache.get(key); 125 + 126 + if (!entry) { 127 + this.stats.misses++; 128 + return null; 129 + } 130 + 131 + this.stats.hits++; 132 + 133 + // Create a readable stream from the buffer 134 + const stream = Readable.from([entry.data]); 135 + 136 + return { stream, metadata: entry.metadata }; 137 + } 138 + 139 + /** 140 + * Store data from a readable stream. 141 + * 142 + * @param key - The key to store under 143 + * @param stream - Readable stream of data to store 144 + * @param metadata - Metadata to store alongside the data 145 + * 146 + * @remarks 147 + * Buffers the stream into memory. For memory tier, this is unavoidable 148 + * since the tier stores data in memory. Use disk or S3 tiers for 149 + * truly streaming large file handling. 150 + */ 151 + async setStream( 152 + key: string, 153 + stream: NodeJS.ReadableStream, 154 + metadata: StorageMetadata, 155 + ): Promise<void> { 156 + const chunks: Uint8Array[] = []; 157 + 158 + for await (const chunk of stream) { 159 + if (Buffer.isBuffer(chunk)) { 160 + chunks.push(new Uint8Array(chunk)); 161 + } else if (ArrayBuffer.isView(chunk)) { 162 + chunks.push(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)); 163 + } else if (typeof chunk === 'string') { 164 + chunks.push(new TextEncoder().encode(chunk)); 165 + } 166 + } 167 + 168 + const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0); 169 + const data = new Uint8Array(totalLength); 170 + let offset = 0; 171 + for (const chunk of chunks) { 172 + data.set(chunk, offset); 173 + offset += chunk.length; 174 + } 175 + 176 + await this.set(key, data, metadata); 177 + } 178 + 179 + async set(key: string, data: Uint8Array, metadata: StorageMetadata): Promise<void> { 180 + const size = data.byteLength; 181 + 182 + // Check existing entry for size accounting 183 + const existing = this.cache.get(key); 184 + if (existing) { 185 + this.currentSize -= existing.size; 186 + } 187 + 188 + // Evict entries until we have space for the new entry 189 + await this.evictIfNeeded(size); 190 + 191 + // Add new entry 192 + const entry: CacheEntry = { data, metadata, size }; 193 + this.cache.set(key, entry); 194 + this.currentSize += size; 195 + } 196 + 197 + async delete(key: string): Promise<void> { 198 + const entry = this.cache.get(key); 199 + if (entry) { 200 + this.cache.delete(key); 201 + this.currentSize -= entry.size; 202 + } 203 + } 204 + 205 + async exists(key: string): Promise<boolean> { 206 + return this.cache.has(key); 207 + } 208 + 209 + async *listKeys(prefix?: string): AsyncIterableIterator<string> { 210 + // TinyLRU returns keys as any[] but they are strings in our usage 211 + const keys = this.cache.keys() as string[]; 212 + for (const key of keys) { 213 + if (!prefix || key.startsWith(prefix)) { 214 + yield key; 215 + } 216 + } 217 + } 218 + 219 + async deleteMany(keys: string[]): Promise<void> { 220 + for (const key of keys) { 221 + await this.delete(key); 222 + } 223 + } 224 + 225 + async getMetadata(key: string): Promise<StorageMetadata | null> { 226 + const entry = this.cache.get(key); 227 + return entry ? entry.metadata : null; 228 + } 229 + 230 + async setMetadata(key: string, metadata: StorageMetadata): Promise<void> { 231 + const entry = this.cache.get(key); 232 + if (entry) { 233 + // Update metadata in place 234 + entry.metadata = metadata; 235 + // Re-set to mark as recently used 236 + this.cache.set(key, entry); 237 + } 238 + } 239 + 240 + async getStats(): Promise<TierStats> { 241 + return { 242 + bytes: this.currentSize, 243 + items: this.cache.size, 244 + hits: this.stats.hits, 245 + misses: this.stats.misses, 246 + evictions: this.stats.evictions, 247 + }; 248 + } 249 + 250 + async clear(): Promise<void> { 251 + this.cache.clear(); 252 + this.currentSize = 0; 253 + } 254 + 255 + /** 256 + * Evict least-recently-used entries until there's space for new data. 257 + * 258 + * @param incomingSize - Size of data being added 259 + * 260 + * @remarks 261 + * TinyLRU handles count-based eviction automatically. 262 + * This method handles size-based eviction by using TinyLRU's built-in evict() method, 263 + * which properly removes the LRU item without updating access order. 264 + */ 265 + private async evictIfNeeded(incomingSize: number): Promise<void> { 266 + // Keep evicting until we have enough space 267 + while (this.currentSize + incomingSize > this.config.maxSizeBytes && this.cache.size > 0) { 268 + // Get the LRU key (first in the list) without accessing it 269 + const keys = this.cache.keys() as string[]; 270 + if (keys.length === 0) break; 271 + 272 + const lruKey = keys[0]; 273 + if (!lruKey) break; 274 + 275 + // Access the entry directly from internal items without triggering LRU update 276 + // items is a public property in LRU interface for this purpose 277 + const entry = this.cache.items[lruKey]?.value; 278 + if (!entry) break; 279 + 280 + // Use TinyLRU's built-in evict() which properly removes the LRU item 281 + this.cache.evict(); 282 + this.currentSize -= entry.size; 283 + this.stats.evictions++; 284 + } 285 + } 286 + }
+781
packages/@wispplace/tiered-storage/src/tiers/S3StorageTier.ts
··· 1 + import { 2 + S3Client, 3 + PutObjectCommand, 4 + GetObjectCommand, 5 + DeleteObjectCommand, 6 + HeadObjectCommand, 7 + ListObjectsV2Command, 8 + DeleteObjectsCommand, 9 + CopyObjectCommand, 10 + type S3ClientConfig, 11 + } from '@aws-sdk/client-s3'; 12 + import { Upload } from '@aws-sdk/lib-storage'; 13 + import type { Readable } from 'node:stream'; 14 + import type { 15 + StorageTier, 16 + StorageMetadata, 17 + TierStats, 18 + TierGetResult, 19 + TierStreamResult, 20 + } from '../types/index.js'; 21 + 22 + /** 23 + * Configuration for S3StorageTier. 24 + */ 25 + export interface S3StorageTierConfig { 26 + /** 27 + * S3 bucket name. 28 + */ 29 + bucket: string; 30 + 31 + /** 32 + * AWS region. 33 + */ 34 + region: string; 35 + 36 + /** 37 + * Optional S3-compatible endpoint (for R2, Minio, etc.). 38 + * 39 + * @example 'https://s3.us-east-1.amazonaws.com' 40 + * @example 'https://account-id.r2.cloudflarestorage.com' 41 + */ 42 + endpoint?: string; 43 + 44 + /** 45 + * Optional AWS credentials. 46 + * 47 + * @remarks 48 + * If not provided, uses the default AWS credential chain 49 + * (environment variables, ~/.aws/credentials, IAM roles, etc.) 50 + */ 51 + credentials?: { 52 + accessKeyId: string; 53 + secretAccessKey: string; 54 + }; 55 + 56 + /** 57 + * Optional key prefix for namespacing. 58 + * 59 + * @remarks 60 + * All keys will be prefixed with this value. 61 + * Useful for multi-tenant scenarios or organizing data. 62 + * 63 + * @example 'tiered-storage/' 64 + */ 65 + prefix?: string; 66 + 67 + /** 68 + * Force path-style addressing for S3-compatible services. 69 + * 70 + * @defaultValue true 71 + * 72 + * @remarks 73 + * Most S3-compatible services (MinIO, R2, etc.) require path-style URLs. 74 + * AWS S3 uses virtual-hosted-style by default, but path-style also works. 75 + * 76 + * - true: `https://endpoint.com/bucket/key` (path-style) 77 + * - false: `https://bucket.endpoint.com/key` (virtual-hosted-style) 78 + */ 79 + forcePathStyle?: boolean; 80 + 81 + /** 82 + * Optional separate bucket for storing metadata. 83 + * 84 + * @remarks 85 + * **RECOMMENDED for production use!** 86 + * 87 + * By default, metadata is stored in S3 object metadata fields. However, updating 88 + * metadata requires copying the entire object, which is slow and expensive for large files. 89 + * 90 + * When `metadataBucket` is specified, metadata is stored as separate JSON objects 91 + * in this bucket. This allows fast, cheap metadata updates without copying data. 92 + * 93 + * **Benefits:** 94 + * - Fast metadata updates (no object copying) 95 + * - Much cheaper for large objects 96 + * - No impact on data object performance 97 + * 98 + * **Trade-offs:** 99 + * - Requires managing two buckets 100 + * - Metadata and data could become out of sync if not handled carefully 101 + * - Additional S3 API calls for metadata operations 102 + * 103 + * @example 104 + * ```typescript 105 + * const tier = new S3StorageTier({ 106 + * bucket: 'my-data-bucket', 107 + * metadataBucket: 'my-metadata-bucket', // Separate bucket for metadata 108 + * region: 'us-east-1', 109 + * }); 110 + * ``` 111 + */ 112 + metadataBucket?: string; 113 + } 114 + 115 + /** 116 + * AWS S3 (or compatible) storage tier. 117 + * 118 + * @remarks 119 + * - Supports AWS S3, Cloudflare R2, MinIO, and other S3-compatible services 120 + * - Uses object metadata for StorageMetadata 121 + * - Requires `@aws-sdk/client-s3` peer dependency 122 + * - Typically used as the cold tier (source of truth) 123 + * 124 + * **Metadata Storage:** 125 + * Metadata is stored in S3 object metadata fields: 126 + * - Custom metadata fields are prefixed with `x-amz-meta-` 127 + * - Built-in fields use standard S3 headers 128 + * 129 + * @example 130 + * ```typescript 131 + * const tier = new S3StorageTier({ 132 + * bucket: 'my-bucket', 133 + * region: 'us-east-1', 134 + * credentials: { 135 + * accessKeyId: process.env.AWS_ACCESS_KEY_ID!, 136 + * secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, 137 + * }, 138 + * prefix: 'cache/', 139 + * }); 140 + * ``` 141 + * 142 + * @example Cloudflare R2 143 + * ```typescript 144 + * const tier = new S3StorageTier({ 145 + * bucket: 'my-bucket', 146 + * region: 'auto', 147 + * endpoint: 'https://account-id.r2.cloudflarestorage.com', 148 + * credentials: { 149 + * accessKeyId: process.env.R2_ACCESS_KEY_ID!, 150 + * secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!, 151 + * }, 152 + * }); 153 + * ``` 154 + */ 155 + export class S3StorageTier implements StorageTier { 156 + private client: S3Client; 157 + private prefix: string; 158 + private metadataBucket?: string; 159 + 160 + constructor(private config: S3StorageTierConfig) { 161 + const clientConfig: S3ClientConfig = { 162 + region: config.region, 163 + // Most S3-compatible services need path-style URLs 164 + forcePathStyle: config.forcePathStyle ?? true, 165 + ...(config.endpoint && { endpoint: config.endpoint }), 166 + ...(config.credentials && { credentials: config.credentials }), 167 + }; 168 + 169 + this.client = new S3Client(clientConfig); 170 + this.prefix = config.prefix ?? ''; 171 + if (config.metadataBucket) { 172 + this.metadataBucket = config.metadataBucket; 173 + } 174 + } 175 + 176 + async get(key: string): Promise<Uint8Array | null> { 177 + try { 178 + const command = new GetObjectCommand({ 179 + Bucket: this.config.bucket, 180 + Key: this.getS3Key(key), 181 + }); 182 + 183 + const response = await this.client.send(command); 184 + 185 + if (!response.Body) { 186 + return null; 187 + } 188 + 189 + return await this.streamToUint8Array(response.Body as Readable); 190 + } catch (error) { 191 + if (this.isNoSuchKeyError(error)) { 192 + return null; 193 + } 194 + throw error; 195 + } 196 + } 197 + 198 + /** 199 + * Retrieve data and metadata together in a single operation. 200 + * 201 + * @param key - The key to retrieve 202 + * @returns The data and metadata, or null if not found 203 + * 204 + * @remarks 205 + * When using a separate metadata bucket, fetches data and metadata in parallel. 206 + * Otherwise, uses the data object's embedded metadata. 207 + */ 208 + async getWithMetadata(key: string): Promise<TierGetResult | null> { 209 + const s3Key = this.getS3Key(key); 210 + 211 + try { 212 + if (this.metadataBucket) { 213 + // Fetch data and metadata in parallel 214 + const [dataResponse, metadataResponse] = await Promise.all([ 215 + this.client.send( 216 + new GetObjectCommand({ 217 + Bucket: this.config.bucket, 218 + Key: s3Key, 219 + }), 220 + ), 221 + this.client.send( 222 + new GetObjectCommand({ 223 + Bucket: this.metadataBucket, 224 + Key: s3Key + '.meta', 225 + }), 226 + ), 227 + ]); 228 + 229 + if (!dataResponse.Body || !metadataResponse.Body) { 230 + return null; 231 + } 232 + 233 + const [data, metaBuffer] = await Promise.all([ 234 + this.streamToUint8Array(dataResponse.Body as Readable), 235 + this.streamToUint8Array(metadataResponse.Body as Readable), 236 + ]); 237 + 238 + const json = new TextDecoder().decode(metaBuffer); 239 + const metadata = JSON.parse(json) as StorageMetadata; 240 + metadata.createdAt = new Date(metadata.createdAt); 241 + metadata.lastAccessed = new Date(metadata.lastAccessed); 242 + if (metadata.ttl) { 243 + metadata.ttl = new Date(metadata.ttl); 244 + } 245 + 246 + return { data, metadata }; 247 + } else { 248 + // Get data with embedded metadata from response headers 249 + const response = await this.client.send( 250 + new GetObjectCommand({ 251 + Bucket: this.config.bucket, 252 + Key: s3Key, 253 + }), 254 + ); 255 + 256 + if (!response.Body || !response.Metadata) { 257 + return null; 258 + } 259 + 260 + const data = await this.streamToUint8Array(response.Body as Readable); 261 + const metadata = this.s3ToMetadata(response.Metadata); 262 + 263 + return { data, metadata }; 264 + } 265 + } catch (error) { 266 + if (this.isNoSuchKeyError(error)) { 267 + return null; 268 + } 269 + throw error; 270 + } 271 + } 272 + 273 + /** 274 + * Retrieve data as a readable stream with metadata. 275 + * 276 + * @param key - The key to retrieve 277 + * @returns A readable stream and metadata, or null if not found 278 + * 279 + * @remarks 280 + * Use this for large files to avoid loading entire content into memory. 281 + * The stream must be consumed or destroyed by the caller. 282 + */ 283 + async getStream(key: string): Promise<TierStreamResult | null> { 284 + const s3Key = this.getS3Key(key); 285 + 286 + try { 287 + if (this.metadataBucket) { 288 + // Fetch data stream and metadata in parallel 289 + const [dataResponse, metadataResponse] = await Promise.all([ 290 + this.client.send( 291 + new GetObjectCommand({ 292 + Bucket: this.config.bucket, 293 + Key: s3Key, 294 + }), 295 + ), 296 + this.client.send( 297 + new GetObjectCommand({ 298 + Bucket: this.metadataBucket, 299 + Key: s3Key + '.meta', 300 + }), 301 + ), 302 + ]); 303 + 304 + if (!dataResponse.Body || !metadataResponse.Body) { 305 + return null; 306 + } 307 + 308 + // Only buffer the small metadata, stream the data 309 + const metaBuffer = await this.streamToUint8Array(metadataResponse.Body as Readable); 310 + const json = new TextDecoder().decode(metaBuffer); 311 + const metadata = JSON.parse(json) as StorageMetadata; 312 + metadata.createdAt = new Date(metadata.createdAt); 313 + metadata.lastAccessed = new Date(metadata.lastAccessed); 314 + if (metadata.ttl) { 315 + metadata.ttl = new Date(metadata.ttl); 316 + } 317 + 318 + return { stream: dataResponse.Body as Readable, metadata }; 319 + } else { 320 + // Get data stream with embedded metadata from response headers 321 + const response = await this.client.send( 322 + new GetObjectCommand({ 323 + Bucket: this.config.bucket, 324 + Key: s3Key, 325 + }), 326 + ); 327 + 328 + if (!response.Body || !response.Metadata) { 329 + return null; 330 + } 331 + 332 + const metadata = this.s3ToMetadata(response.Metadata); 333 + 334 + return { stream: response.Body as Readable, metadata }; 335 + } 336 + } catch (error) { 337 + if (this.isNoSuchKeyError(error)) { 338 + return null; 339 + } 340 + throw error; 341 + } 342 + } 343 + 344 + /** 345 + * Store data from a readable stream. 346 + * 347 + * @param key - The key to store under 348 + * @param stream - Readable stream of data to store 349 + * @param metadata - Metadata to store alongside the data 350 + * 351 + * @remarks 352 + * Uses multipart upload for efficient streaming of large files. 353 + * The stream will be fully consumed by this operation. 354 + */ 355 + async setStream( 356 + key: string, 357 + stream: NodeJS.ReadableStream, 358 + metadata: StorageMetadata, 359 + ): Promise<void> { 360 + const s3Key = this.getS3Key(key); 361 + 362 + if (this.metadataBucket) { 363 + // Use multipart upload for streaming data 364 + const upload = new Upload({ 365 + client: this.client, 366 + params: { 367 + Bucket: this.config.bucket, 368 + Key: s3Key, 369 + Body: stream as Readable, 370 + }, 371 + }); 372 + 373 + const metadataJson = JSON.stringify(metadata); 374 + const metadataBuffer = new TextEncoder().encode(metadataJson); 375 + const metadataCommand = new PutObjectCommand({ 376 + Bucket: this.metadataBucket, 377 + Key: s3Key + '.meta', 378 + Body: metadataBuffer, 379 + ContentType: 'application/json', 380 + }); 381 + 382 + await Promise.all([upload.done(), this.client.send(metadataCommand)]); 383 + } else { 384 + // Use multipart upload with embedded metadata 385 + const upload = new Upload({ 386 + client: this.client, 387 + params: { 388 + Bucket: this.config.bucket, 389 + Key: s3Key, 390 + Body: stream as Readable, 391 + Metadata: this.metadataToS3(metadata), 392 + }, 393 + }); 394 + 395 + await upload.done(); 396 + } 397 + } 398 + 399 + private async streamToUint8Array(stream: Readable): Promise<Uint8Array> { 400 + const chunks: Uint8Array[] = []; 401 + 402 + for await (const chunk of stream) { 403 + if (Buffer.isBuffer(chunk)) { 404 + chunks.push(new Uint8Array(chunk)); 405 + } else if (chunk instanceof Uint8Array) { 406 + chunks.push(chunk); 407 + } else { 408 + throw new Error('Unexpected chunk type in S3 stream'); 409 + } 410 + } 411 + 412 + const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0); 413 + const result = new Uint8Array(totalLength); 414 + let offset = 0; 415 + for (const chunk of chunks) { 416 + result.set(chunk, offset); 417 + offset += chunk.length; 418 + } 419 + 420 + return result; 421 + } 422 + 423 + private isNoSuchKeyError(error: unknown): boolean { 424 + return ( 425 + typeof error === 'object' && 426 + error !== null && 427 + 'name' in error && 428 + (error.name === 'NoSuchKey' || error.name === 'NotFound') 429 + ); 430 + } 431 + 432 + async set(key: string, data: Uint8Array, metadata: StorageMetadata): Promise<void> { 433 + const s3Key = this.getS3Key(key); 434 + 435 + if (this.metadataBucket) { 436 + const dataCommand = new PutObjectCommand({ 437 + Bucket: this.config.bucket, 438 + Key: s3Key, 439 + Body: data, 440 + ContentLength: data.byteLength, 441 + }); 442 + 443 + const metadataJson = JSON.stringify(metadata); 444 + const metadataBuffer = new TextEncoder().encode(metadataJson); 445 + const metadataCommand = new PutObjectCommand({ 446 + Bucket: this.metadataBucket, 447 + Key: s3Key + '.meta', 448 + Body: metadataBuffer, 449 + ContentType: 'application/json', 450 + }); 451 + 452 + await Promise.all([this.client.send(dataCommand), this.client.send(metadataCommand)]); 453 + } else { 454 + const command = new PutObjectCommand({ 455 + Bucket: this.config.bucket, 456 + Key: s3Key, 457 + Body: data, 458 + ContentLength: data.byteLength, 459 + Metadata: this.metadataToS3(metadata), 460 + }); 461 + 462 + await this.client.send(command); 463 + } 464 + } 465 + 466 + async delete(key: string): Promise<void> { 467 + const s3Key = this.getS3Key(key); 468 + 469 + try { 470 + const dataCommand = new DeleteObjectCommand({ 471 + Bucket: this.config.bucket, 472 + Key: s3Key, 473 + }); 474 + 475 + if (this.metadataBucket) { 476 + const metadataCommand = new DeleteObjectCommand({ 477 + Bucket: this.metadataBucket, 478 + Key: s3Key + '.meta', 479 + }); 480 + 481 + await Promise.all([ 482 + this.client.send(dataCommand), 483 + this.client.send(metadataCommand).catch((error) => { 484 + if (!this.isNoSuchKeyError(error)) throw error; 485 + }), 486 + ]); 487 + } else { 488 + await this.client.send(dataCommand); 489 + } 490 + } catch (error) { 491 + if (!this.isNoSuchKeyError(error)) { 492 + throw error; 493 + } 494 + } 495 + } 496 + 497 + async exists(key: string): Promise<boolean> { 498 + try { 499 + const command = new HeadObjectCommand({ 500 + Bucket: this.config.bucket, 501 + Key: this.getS3Key(key), 502 + }); 503 + 504 + await this.client.send(command); 505 + return true; 506 + } catch (error) { 507 + if (this.isNoSuchKeyError(error)) { 508 + return false; 509 + } 510 + throw error; 511 + } 512 + } 513 + 514 + async *listKeys(prefix?: string): AsyncIterableIterator<string> { 515 + const s3Prefix = prefix ? this.getS3Key(prefix) : this.prefix; 516 + let continuationToken: string | undefined; 517 + 518 + do { 519 + const command = new ListObjectsV2Command({ 520 + Bucket: this.config.bucket, 521 + Prefix: s3Prefix, 522 + ContinuationToken: continuationToken, 523 + }); 524 + 525 + const response = await this.client.send(command); 526 + 527 + if (response.Contents) { 528 + for (const object of response.Contents) { 529 + if (object.Key) { 530 + // Remove prefix to get original key 531 + const key = this.removePrefix(object.Key); 532 + yield key; 533 + } 534 + } 535 + } 536 + 537 + continuationToken = response.NextContinuationToken; 538 + } while (continuationToken); 539 + } 540 + 541 + async deleteMany(keys: string[]): Promise<void> { 542 + if (keys.length === 0) return; 543 + 544 + const batchSize = 1000; 545 + 546 + for (let i = 0; i < keys.length; i += batchSize) { 547 + const batch = keys.slice(i, i + batchSize); 548 + 549 + const dataCommand = new DeleteObjectsCommand({ 550 + Bucket: this.config.bucket, 551 + Delete: { 552 + Objects: batch.map((key) => ({ Key: this.getS3Key(key) })), 553 + }, 554 + }); 555 + 556 + if (this.metadataBucket) { 557 + const metadataCommand = new DeleteObjectsCommand({ 558 + Bucket: this.metadataBucket, 559 + Delete: { 560 + Objects: batch.map((key) => ({ Key: this.getS3Key(key) + '.meta' })), 561 + }, 562 + }); 563 + 564 + await Promise.all([ 565 + this.client.send(dataCommand), 566 + this.client.send(metadataCommand).catch(() => {}), 567 + ]); 568 + } else { 569 + await this.client.send(dataCommand); 570 + } 571 + } 572 + } 573 + 574 + async getMetadata(key: string): Promise<StorageMetadata | null> { 575 + if (this.metadataBucket) { 576 + try { 577 + const command = new GetObjectCommand({ 578 + Bucket: this.metadataBucket, 579 + Key: this.getS3Key(key) + '.meta', 580 + }); 581 + 582 + const response = await this.client.send(command); 583 + 584 + if (!response.Body) { 585 + return null; 586 + } 587 + 588 + const buffer = await this.streamToUint8Array(response.Body as Readable); 589 + const json = new TextDecoder().decode(buffer); 590 + const metadata = JSON.parse(json) as StorageMetadata; 591 + 592 + metadata.createdAt = new Date(metadata.createdAt); 593 + metadata.lastAccessed = new Date(metadata.lastAccessed); 594 + if (metadata.ttl) { 595 + metadata.ttl = new Date(metadata.ttl); 596 + } 597 + 598 + return metadata; 599 + } catch (error) { 600 + if (this.isNoSuchKeyError(error)) { 601 + return null; 602 + } 603 + throw error; 604 + } 605 + } 606 + 607 + try { 608 + const command = new HeadObjectCommand({ 609 + Bucket: this.config.bucket, 610 + Key: this.getS3Key(key), 611 + }); 612 + 613 + const response = await this.client.send(command); 614 + 615 + if (!response.Metadata) { 616 + return null; 617 + } 618 + 619 + return this.s3ToMetadata(response.Metadata); 620 + } catch (error) { 621 + if (this.isNoSuchKeyError(error)) { 622 + return null; 623 + } 624 + throw error; 625 + } 626 + } 627 + 628 + async setMetadata(key: string, metadata: StorageMetadata): Promise<void> { 629 + if (this.metadataBucket) { 630 + const metadataJson = JSON.stringify(metadata); 631 + const buffer = new TextEncoder().encode(metadataJson); 632 + 633 + const command = new PutObjectCommand({ 634 + Bucket: this.metadataBucket, 635 + Key: this.getS3Key(key) + '.meta', 636 + Body: buffer, 637 + ContentType: 'application/json', 638 + }); 639 + 640 + await this.client.send(command); 641 + return; 642 + } 643 + 644 + const s3Key = this.getS3Key(key); 645 + const command = new CopyObjectCommand({ 646 + Bucket: this.config.bucket, 647 + Key: s3Key, 648 + CopySource: `${this.config.bucket}/${s3Key}`, 649 + Metadata: this.metadataToS3(metadata), 650 + MetadataDirective: 'REPLACE', 651 + }); 652 + 653 + await this.client.send(command); 654 + } 655 + 656 + async getStats(): Promise<TierStats> { 657 + let bytes = 0; 658 + let items = 0; 659 + 660 + // List all objects and sum up sizes 661 + let continuationToken: string | undefined; 662 + 663 + do { 664 + const command = new ListObjectsV2Command({ 665 + Bucket: this.config.bucket, 666 + Prefix: this.prefix, 667 + ContinuationToken: continuationToken, 668 + }); 669 + 670 + const response = await this.client.send(command); 671 + 672 + if (response.Contents) { 673 + for (const object of response.Contents) { 674 + items++; 675 + bytes += object.Size ?? 0; 676 + } 677 + } 678 + 679 + continuationToken = response.NextContinuationToken; 680 + } while (continuationToken); 681 + 682 + return { bytes, items }; 683 + } 684 + 685 + async clear(): Promise<void> { 686 + // List and delete all objects with the prefix 687 + const keys: string[] = []; 688 + 689 + for await (const key of this.listKeys()) { 690 + keys.push(key); 691 + } 692 + 693 + await this.deleteMany(keys); 694 + } 695 + 696 + /** 697 + * Get the full S3 key including prefix. 698 + */ 699 + private getS3Key(key: string): string { 700 + return this.prefix + key; 701 + } 702 + 703 + /** 704 + * Remove the prefix from an S3 key to get the original key. 705 + */ 706 + private removePrefix(s3Key: string): string { 707 + if (this.prefix && s3Key.startsWith(this.prefix)) { 708 + return s3Key.slice(this.prefix.length); 709 + } 710 + return s3Key; 711 + } 712 + 713 + /** 714 + * Convert StorageMetadata to S3 metadata format. 715 + * 716 + * @remarks 717 + * S3 metadata keys must be lowercase and values must be strings. 718 + * We serialize complex values as JSON. 719 + */ 720 + private metadataToS3(metadata: StorageMetadata): Record<string, string> { 721 + return { 722 + key: metadata.key, 723 + size: metadata.size.toString(), 724 + createdat: metadata.createdAt.toISOString(), 725 + lastaccessed: metadata.lastAccessed.toISOString(), 726 + accesscount: metadata.accessCount.toString(), 727 + compressed: metadata.compressed.toString(), 728 + checksum: metadata.checksum, 729 + ...(metadata.ttl && { ttl: metadata.ttl.toISOString() }), 730 + ...(metadata.mimeType && { mimetype: metadata.mimeType }), 731 + ...(metadata.encoding && { encoding: metadata.encoding }), 732 + ...(metadata.customMetadata && { custom: JSON.stringify(metadata.customMetadata) }), 733 + }; 734 + } 735 + 736 + /** 737 + * Convert S3 metadata to StorageMetadata format. 738 + */ 739 + private s3ToMetadata(s3Metadata: Record<string, string>): StorageMetadata { 740 + const metadata: StorageMetadata = { 741 + key: s3Metadata.key ?? '', 742 + size: parseInt(s3Metadata.size ?? '0', 10), 743 + createdAt: new Date(s3Metadata.createdat ?? Date.now()), 744 + lastAccessed: new Date(s3Metadata.lastaccessed ?? Date.now()), 745 + accessCount: parseInt(s3Metadata.accesscount ?? '0', 10), 746 + compressed: s3Metadata.compressed === 'true', 747 + checksum: s3Metadata.checksum ?? '', 748 + }; 749 + 750 + if (s3Metadata.ttl) { 751 + metadata.ttl = new Date(s3Metadata.ttl); 752 + } 753 + 754 + if (s3Metadata.mimetype) { 755 + metadata.mimeType = s3Metadata.mimetype; 756 + } 757 + 758 + if (s3Metadata.encoding) { 759 + metadata.encoding = s3Metadata.encoding; 760 + } 761 + 762 + if (s3Metadata.custom) { 763 + try { 764 + const parsed: unknown = JSON.parse(s3Metadata.custom); 765 + // Validate it's a Record<string, string> 766 + if ( 767 + parsed && 768 + typeof parsed === 'object' && 769 + !Array.isArray(parsed) && 770 + Object.values(parsed).every((v) => typeof v === 'string') 771 + ) { 772 + metadata.customMetadata = parsed as Record<string, string>; 773 + } 774 + } catch { 775 + // Ignore invalid JSON 776 + } 777 + } 778 + 779 + return metadata; 780 + } 781 + }
+579
packages/@wispplace/tiered-storage/src/types/index.ts
··· 1 + /** 2 + * Metadata associated with stored data in a tier. 3 + * 4 + * @remarks 5 + * This metadata is stored alongside the actual data and is used for: 6 + * - TTL management and expiration 7 + * - Access tracking for LRU/eviction policies 8 + * - Data integrity verification via checksum 9 + * - Content type information for HTTP serving 10 + */ 11 + export interface StorageMetadata { 12 + /** Original key used to store the data (human-readable) */ 13 + key: string; 14 + 15 + /** Size of the data in bytes (uncompressed size) */ 16 + size: number; 17 + 18 + /** Timestamp when the data was first created */ 19 + createdAt: Date; 20 + 21 + /** Timestamp when the data was last accessed */ 22 + lastAccessed: Date; 23 + 24 + /** Number of times this data has been accessed */ 25 + accessCount: number; 26 + 27 + /** Optional expiration timestamp. Data expires when current time > ttl */ 28 + ttl?: Date; 29 + 30 + /** Whether the data is compressed (e.g., with gzip) */ 31 + compressed: boolean; 32 + 33 + /** SHA256 checksum of the data for integrity verification */ 34 + checksum: string; 35 + 36 + /** Optional MIME type (e.g., 'text/html', 'application/json') */ 37 + mimeType?: string; 38 + 39 + /** Optional encoding (e.g., 'gzip', 'base64') */ 40 + encoding?: string; 41 + 42 + /** User-defined metadata fields */ 43 + customMetadata?: Record<string, string>; 44 + } 45 + 46 + /** 47 + * Statistics for a single storage tier. 48 + * 49 + * @remarks 50 + * Used for monitoring cache performance and capacity planning. 51 + */ 52 + export interface TierStats { 53 + /** Total bytes stored in this tier */ 54 + bytes: number; 55 + 56 + /** Total number of items stored in this tier */ 57 + items: number; 58 + 59 + /** Number of cache hits (only tracked if tier implements hit tracking) */ 60 + hits?: number; 61 + 62 + /** Number of cache misses (only tracked if tier implements miss tracking) */ 63 + misses?: number; 64 + 65 + /** Number of evictions due to size/count limits (only tracked if tier implements eviction) */ 66 + evictions?: number; 67 + } 68 + 69 + /** 70 + * Aggregated statistics across all configured tiers. 71 + * 72 + * @remarks 73 + * Provides a complete view of cache performance across the entire storage hierarchy. 74 + */ 75 + export interface AllTierStats { 76 + /** Statistics for hot tier (if configured) */ 77 + hot?: TierStats; 78 + 79 + /** Statistics for warm tier (if configured) */ 80 + warm?: TierStats; 81 + 82 + /** Statistics for cold tier (always present) */ 83 + cold: TierStats; 84 + 85 + /** Total hits across all tiers */ 86 + totalHits: number; 87 + 88 + /** Total misses across all tiers */ 89 + totalMisses: number; 90 + 91 + /** Hit rate as a percentage (0-1) */ 92 + hitRate: number; 93 + } 94 + 95 + /** 96 + * Interface that all storage tier implementations must satisfy. 97 + * 98 + * @remarks 99 + * This is the core abstraction that allows pluggable backends. 100 + * Implementations can be memory-based (Map, Redis), disk-based (filesystem, SQLite), 101 + * or cloud-based (S3, R2, etc.). 102 + * 103 + * @example 104 + * ```typescript 105 + * class RedisStorageTier implements StorageTier { 106 + * constructor(private client: RedisClient) {} 107 + * 108 + * async get(key: string): Promise<Uint8Array | null> { 109 + * const buffer = await this.client.getBuffer(key); 110 + * return buffer ? new Uint8Array(buffer) : null; 111 + * } 112 + * 113 + * // ... implement other methods 114 + * } 115 + * ``` 116 + */ 117 + /** 118 + * Result from a combined get+metadata operation on a tier. 119 + */ 120 + export interface TierGetResult { 121 + /** The retrieved data */ 122 + data: Uint8Array; 123 + /** Metadata associated with the data */ 124 + metadata: StorageMetadata; 125 + } 126 + 127 + /** 128 + * Result from a streaming get operation on a tier. 129 + */ 130 + export interface TierStreamResult { 131 + /** Readable stream of the data */ 132 + stream: NodeJS.ReadableStream; 133 + /** Metadata associated with the data */ 134 + metadata: StorageMetadata; 135 + } 136 + 137 + /** 138 + * Result from a streaming get operation on TieredStorage. 139 + * 140 + * @remarks 141 + * Includes the source tier for observability. 142 + */ 143 + export interface StreamResult { 144 + /** Readable stream of the data */ 145 + stream: NodeJS.ReadableStream; 146 + /** Metadata associated with the data */ 147 + metadata: StorageMetadata; 148 + /** Which tier the data was served from */ 149 + source: 'hot' | 'warm' | 'cold'; 150 + } 151 + 152 + /** 153 + * Options for streaming set operations. 154 + */ 155 + export interface StreamSetOptions extends SetOptions { 156 + /** 157 + * Size of the data being streamed in bytes. 158 + * 159 + * @remarks 160 + * Required for streaming writes because the size cannot be determined 161 + * until the stream is fully consumed. This is used for: 162 + * - Metadata creation before streaming starts 163 + * - Capacity checks and eviction in tiers with size limits 164 + */ 165 + size: number; 166 + 167 + /** 168 + * Pre-computed checksum of the data. 169 + * 170 + * @remarks 171 + * If not provided, checksum will be computed during streaming. 172 + * Providing it upfront is useful when the checksum is already known 173 + * (e.g., from a previous upload or external source). 174 + */ 175 + checksum?: string; 176 + 177 + /** 178 + * MIME type of the content. 179 + */ 180 + mimeType?: string; 181 + } 182 + 183 + export interface StorageTier { 184 + /** 185 + * Retrieve data for a key. 186 + * 187 + * @param key - The key to retrieve 188 + * @returns The data as a Uint8Array, or null if not found 189 + */ 190 + get(key: string): Promise<Uint8Array | null>; 191 + 192 + /** 193 + * Retrieve data and metadata together in a single operation. 194 + * 195 + * @param key - The key to retrieve 196 + * @returns The data and metadata, or null if not found 197 + * 198 + * @remarks 199 + * This is more efficient than calling get() and getMetadata() separately, 200 + * especially for disk and network-based tiers. 201 + */ 202 + getWithMetadata?(key: string): Promise<TierGetResult | null>; 203 + 204 + /** 205 + * Retrieve data as a readable stream with metadata. 206 + * 207 + * @param key - The key to retrieve 208 + * @returns A readable stream and metadata, or null if not found 209 + * 210 + * @remarks 211 + * Use this for large files to avoid loading entire content into memory. 212 + * The stream must be consumed or destroyed by the caller. 213 + */ 214 + getStream?(key: string): Promise<TierStreamResult | null>; 215 + 216 + /** 217 + * Store data from a readable stream. 218 + * 219 + * @param key - The key to store under 220 + * @param stream - Readable stream of data to store 221 + * @param metadata - Metadata to store alongside the data 222 + * 223 + * @remarks 224 + * Use this for large files to avoid loading entire content into memory. 225 + * The stream will be fully consumed by this operation. 226 + */ 227 + setStream?( 228 + key: string, 229 + stream: NodeJS.ReadableStream, 230 + metadata: StorageMetadata, 231 + ): Promise<void>; 232 + 233 + /** 234 + * Store data with associated metadata. 235 + * 236 + * @param key - The key to store under 237 + * @param data - The data to store (as Uint8Array) 238 + * @param metadata - Metadata to store alongside the data 239 + * 240 + * @remarks 241 + * If the key already exists, it should be overwritten. 242 + */ 243 + set(key: string, data: Uint8Array, metadata: StorageMetadata): Promise<void>; 244 + 245 + /** 246 + * Delete data for a key. 247 + * 248 + * @param key - The key to delete 249 + * 250 + * @remarks 251 + * Should not throw if the key doesn't exist. 252 + */ 253 + delete(key: string): Promise<void>; 254 + 255 + /** 256 + * Check if a key exists in this tier. 257 + * 258 + * @param key - The key to check 259 + * @returns true if the key exists, false otherwise 260 + */ 261 + exists(key: string): Promise<boolean>; 262 + 263 + /** 264 + * List all keys in this tier, optionally filtered by prefix. 265 + * 266 + * @param prefix - Optional prefix to filter keys (e.g., 'user:' matches 'user:123', 'user:456') 267 + * @returns An async iterator of keys 268 + * 269 + * @remarks 270 + * This should be memory-efficient and stream keys rather than loading all into memory. 271 + * Useful for prefix-based invalidation and cache warming. 272 + * 273 + * @example 274 + * ```typescript 275 + * for await (const key of tier.listKeys('site:')) { 276 + * console.log(key); // 'site:abc', 'site:xyz', etc. 277 + * } 278 + * ``` 279 + */ 280 + listKeys(prefix?: string): AsyncIterableIterator<string>; 281 + 282 + /** 283 + * Delete multiple keys in a single operation. 284 + * 285 + * @param keys - Array of keys to delete 286 + * 287 + * @remarks 288 + * This is more efficient than calling delete() in a loop. 289 + * Implementations should batch deletions where possible. 290 + */ 291 + deleteMany(keys: string[]): Promise<void>; 292 + 293 + /** 294 + * Retrieve metadata for a key without fetching the data. 295 + * 296 + * @param key - The key to get metadata for 297 + * @returns The metadata, or null if not found 298 + * 299 + * @remarks 300 + * This is useful for checking TTL, access counts, etc. without loading large data. 301 + */ 302 + getMetadata(key: string): Promise<StorageMetadata | null>; 303 + 304 + /** 305 + * Update metadata for a key without modifying the data. 306 + * 307 + * @param key - The key to update metadata for 308 + * @param metadata - The new metadata 309 + * 310 + * @remarks 311 + * Useful for updating TTL (via touch()) or access counts. 312 + */ 313 + setMetadata(key: string, metadata: StorageMetadata): Promise<void>; 314 + 315 + /** 316 + * Get statistics about this tier. 317 + * 318 + * @returns Statistics including size, item count, hits, misses, etc. 319 + */ 320 + getStats(): Promise<TierStats>; 321 + 322 + /** 323 + * Clear all data from this tier. 324 + * 325 + * @remarks 326 + * Use with caution! This will delete all data in the tier. 327 + */ 328 + clear(): Promise<void>; 329 + } 330 + 331 + /** 332 + * Rule for automatic tier placement based on key patterns. 333 + * 334 + * @remarks 335 + * Rules are evaluated in order. First matching rule wins. 336 + * Use this to define which keys go to which tiers without 337 + * specifying skipTiers on every set() call. 338 + * 339 + * @example 340 + * ```typescript 341 + * placementRules: [ 342 + * { pattern: 'index.html', tiers: ['hot', 'warm', 'cold'] }, 343 + * { pattern: '*.html', tiers: ['warm', 'cold'] }, 344 + * { pattern: 'assets/**', tiers: ['warm', 'cold'] }, 345 + * { pattern: '**', tiers: ['warm', 'cold'] }, // default 346 + * ] 347 + * ``` 348 + */ 349 + export interface PlacementRule { 350 + /** 351 + * Glob pattern to match against keys. 352 + * 353 + * @remarks 354 + * Supports basic globs: 355 + * - `*` matches any characters except `/` 356 + * - `**` matches any characters including `/` 357 + * - Exact matches work too: `index.html` 358 + */ 359 + pattern: string; 360 + 361 + /** 362 + * Which tiers to write to for matching keys. 363 + * 364 + * @remarks 365 + * Cold is always included (source of truth). 366 + * Use `['hot', 'warm', 'cold']` for critical files. 367 + * Use `['warm', 'cold']` for large files. 368 + * Use `['cold']` for archival only. 369 + */ 370 + tiers: ('hot' | 'warm' | 'cold')[]; 371 + } 372 + 373 + /** 374 + * Configuration for the TieredStorage system. 375 + * 376 + * @typeParam T - The type of data being stored (for serialization) 377 + * 378 + * @remarks 379 + * The tiered storage system uses a cascading containment model: 380 + * - Hot tier (optional): Fastest, smallest capacity (memory/Redis) 381 + * - Warm tier (optional): Medium speed, medium capacity (disk/database) 382 + * - Cold tier (required): Slowest, unlimited capacity (S3/object storage) 383 + * 384 + * Data flows down on writes (hot → warm → cold) and bubbles up on reads (cold → warm → hot). 385 + */ 386 + export interface TieredStorageConfig { 387 + /** Storage tier configuration */ 388 + tiers: { 389 + /** Optional hot tier - fastest, smallest capacity (e.g., in-memory, Redis) */ 390 + hot?: StorageTier; 391 + 392 + /** Optional warm tier - medium speed, medium capacity (e.g., disk, SQLite, Postgres) */ 393 + warm?: StorageTier; 394 + 395 + /** Required cold tier - slowest, largest capacity (e.g., S3, R2, object storage) */ 396 + cold: StorageTier; 397 + }; 398 + 399 + /** Rules for automatic tier placement based on key patterns. First match wins. */ 400 + placementRules?: PlacementRule[]; 401 + 402 + /** 403 + * Whether to automatically compress data before storing. 404 + * 405 + * @defaultValue false 406 + * 407 + * @remarks 408 + * Uses gzip compression. Compression is transparent - data is automatically 409 + * decompressed on retrieval. The `compressed` flag in metadata indicates compression state. 410 + */ 411 + compression?: boolean; 412 + 413 + /** 414 + * Default TTL (time-to-live) in milliseconds. 415 + * 416 + * @remarks 417 + * Data will expire after this duration. Can be overridden per-key via SetOptions. 418 + * If not set, data never expires. 419 + */ 420 + defaultTTL?: number; 421 + 422 + /** 423 + * Strategy for promoting data to upper tiers on cache miss. 424 + * 425 + * @defaultValue 'lazy' 426 + * 427 + * @remarks 428 + * - 'eager': Immediately promote data to all upper tiers on read 429 + * - 'lazy': Don't automatically promote; rely on explicit promotion or next write 430 + * 431 + * Eager promotion increases hot tier hit rate but adds write overhead. 432 + * Lazy promotion reduces writes but may serve from lower tiers more often. 433 + */ 434 + promotionStrategy?: 'eager' | 'lazy'; 435 + 436 + /** 437 + * Custom serialization/deserialization functions. 438 + * 439 + * @remarks 440 + * By default, JSON serialization is used. Provide custom functions for: 441 + * - Non-JSON types (e.g., Buffer, custom classes) 442 + * - Performance optimization (e.g., msgpack, protobuf) 443 + * - Encryption (serialize includes encryption, deserialize includes decryption) 444 + */ 445 + serialization?: { 446 + /** Convert data to Uint8Array for storage */ 447 + serialize: (data: unknown) => Promise<Uint8Array>; 448 + 449 + /** Convert Uint8Array back to original data */ 450 + deserialize: (data: Uint8Array) => Promise<unknown>; 451 + }; 452 + } 453 + 454 + /** 455 + * Options for setting data in the cache. 456 + * 457 + * @remarks 458 + * These options allow fine-grained control over where and how data is stored. 459 + */ 460 + export interface SetOptions { 461 + /** 462 + * Custom TTL in milliseconds for this specific key. 463 + * 464 + * @remarks 465 + * Overrides the default TTL from TieredStorageConfig. 466 + * Data will expire after this duration from the current time. 467 + */ 468 + ttl?: number; 469 + 470 + /** 471 + * Custom metadata to attach to this key. 472 + * 473 + * @remarks 474 + * Merged with system-generated metadata (size, checksum, timestamps). 475 + * Useful for storing application-specific information like content-type, encoding, etc. 476 + */ 477 + metadata?: Record<string, string>; 478 + 479 + /** 480 + * Skip writing to specific tiers. 481 + * 482 + * @remarks 483 + * Useful for controlling which tiers receive data. For example: 484 + * - Large files: `skipTiers: ['hot']` to avoid filling memory 485 + * - Small critical files: Write to hot only for fastest access 486 + * 487 + * Note: Cold tier can never be skipped (it's the source of truth). 488 + * Mutually exclusive with `onlyTiers`. 489 + * 490 + * @example 491 + * ```typescript 492 + * // Store large file only in warm and cold (skip memory) 493 + * await storage.set('large-video.mp4', videoData, { skipTiers: ['hot'] }); 494 + * 495 + * // Store index.html in all tiers for fast access 496 + * await storage.set('index.html', htmlData); // No skipping 497 + * ``` 498 + */ 499 + skipTiers?: ('hot' | 'warm')[]; 500 + 501 + /** 502 + * Write only to specific tiers. 503 + * 504 + * @remarks 505 + * Unlike `skipTiers`, this explicitly specifies which tiers to write to. 506 + * Useful for write-only services that should only populate cold storage. 507 + * Mutually exclusive with `skipTiers`. 508 + * 509 + * @example 510 + * ```typescript 511 + * // Write only to cold tier (S3) - useful for firehose/ingestion services 512 + * await storage.set('site/index.html', htmlData, { onlyTiers: ['cold'] }); 513 + * 514 + * // Write to warm and cold, skip hot 515 + * await storage.set('large-file.mp4', videoData, { onlyTiers: ['warm', 'cold'] }); 516 + * ``` 517 + */ 518 + onlyTiers?: ('hot' | 'warm' | 'cold')[]; 519 + } 520 + 521 + /** 522 + * Result from retrieving data with metadata. 523 + * 524 + * @typeParam T - The type of data being retrieved 525 + * 526 + * @remarks 527 + * Includes both the data and information about where it was served from. 528 + */ 529 + export interface StorageResult<T> { 530 + /** The retrieved data */ 531 + data: T; 532 + 533 + /** Metadata associated with the data */ 534 + metadata: StorageMetadata; 535 + 536 + /** Which tier the data was served from */ 537 + source: 'hot' | 'warm' | 'cold'; 538 + } 539 + 540 + /** 541 + * Result from setting data in the cache. 542 + * 543 + * @remarks 544 + * Indicates which tiers successfully received the data. 545 + */ 546 + export interface SetResult { 547 + /** The key that was set */ 548 + key: string; 549 + 550 + /** Metadata that was stored with the data */ 551 + metadata: StorageMetadata; 552 + 553 + /** Which tiers received the data */ 554 + tiersWritten: ('hot' | 'warm' | 'cold')[]; 555 + } 556 + 557 + /** 558 + * Snapshot of the entire storage state. 559 + * 560 + * @remarks 561 + * Used for export/import, backup, and migration scenarios. 562 + * The snapshot includes metadata but not the actual data (data remains in tiers). 563 + */ 564 + export interface StorageSnapshot { 565 + /** Snapshot format version (for compatibility) */ 566 + version: number; 567 + 568 + /** When this snapshot was created */ 569 + exportedAt: Date; 570 + 571 + /** All keys present in cold tier (source of truth) */ 572 + keys: string[]; 573 + 574 + /** Metadata for each key */ 575 + metadata: Record<string, StorageMetadata>; 576 + 577 + /** Statistics at time of export */ 578 + stats: AllTierStats; 579 + }
+57
packages/@wispplace/tiered-storage/src/utils/checksum.ts
··· 1 + import { createHash, timingSafeEqual } from 'node:crypto'; 2 + 3 + /** 4 + * Calculate SHA256 checksum of data. 5 + * 6 + * @param data - Data to checksum 7 + * @returns Hex-encoded SHA256 hash 8 + * 9 + * @remarks 10 + * Used for data integrity verification. The checksum is stored in metadata 11 + * and can be used to detect corruption or tampering. 12 + * 13 + * @example 14 + * ```typescript 15 + * const data = new TextEncoder().encode('Hello, world!'); 16 + * const checksum = calculateChecksum(data); 17 + * console.log(checksum); // '315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3' 18 + * ``` 19 + */ 20 + export function calculateChecksum(data: Uint8Array): string { 21 + const hash = createHash('sha256'); 22 + hash.update(data); 23 + return hash.digest('hex'); 24 + } 25 + 26 + /** 27 + * Verify that data matches an expected checksum. 28 + * 29 + * @param data - Data to verify 30 + * @param expectedChecksum - Expected SHA256 checksum (hex-encoded) 31 + * @returns true if checksums match, false otherwise 32 + * 33 + * @remarks 34 + * Uses constant-time comparison to prevent timing attacks. 35 + * 36 + * @example 37 + * ```typescript 38 + * const isValid = verifyChecksum(data, metadata.checksum); 39 + * if (!isValid) { 40 + * throw new Error('Data corruption detected'); 41 + * } 42 + * ``` 43 + */ 44 + export function verifyChecksum(data: Uint8Array, expectedChecksum: string): boolean { 45 + const actualChecksum = calculateChecksum(data); 46 + 47 + // Use constant-time comparison to prevent timing attacks 48 + try { 49 + return timingSafeEqual( 50 + Buffer.from(actualChecksum, 'hex'), 51 + Buffer.from(expectedChecksum, 'hex'), 52 + ); 53 + } catch { 54 + // If checksums have different lengths, timingSafeEqual throws 55 + return false; 56 + } 57 + }
+117
packages/@wispplace/tiered-storage/src/utils/compression.ts
··· 1 + import { gzip, gunzip, createGzip, createGunzip } from 'node:zlib'; 2 + import { promisify } from 'node:util'; 3 + import type { Transform } from 'node:stream'; 4 + 5 + const gzipAsync = promisify(gzip); 6 + const gunzipAsync = promisify(gunzip); 7 + 8 + /** 9 + * Compress data using gzip. 10 + * 11 + * @param data - Data to compress 12 + * @returns Compressed data as Uint8Array 13 + * 14 + * @remarks 15 + * Uses Node.js zlib with default compression level (6). 16 + * Compression is transparent to the user - data is automatically decompressed on retrieval. 17 + * 18 + * @example 19 + * ```typescript 20 + * const original = new TextEncoder().encode('Hello, world!'); 21 + * const compressed = await compress(original); 22 + * console.log(`Compressed from ${original.length} to ${compressed.length} bytes`); 23 + * ``` 24 + */ 25 + export async function compress(data: Uint8Array): Promise<Uint8Array> { 26 + const buffer = Buffer.from(data); 27 + const compressed = await gzipAsync(buffer); 28 + return new Uint8Array(compressed); 29 + } 30 + 31 + /** 32 + * Decompress gzip-compressed data. 33 + * 34 + * @param data - Compressed data 35 + * @returns Decompressed data as Uint8Array 36 + * @throws Error if data is not valid gzip format 37 + * 38 + * @remarks 39 + * Automatically validates gzip magic bytes (0x1f 0x8b) before decompression. 40 + * 41 + * @example 42 + * ```typescript 43 + * const decompressed = await decompress(compressedData); 44 + * const text = new TextDecoder().decode(decompressed); 45 + * ``` 46 + */ 47 + export async function decompress(data: Uint8Array): Promise<Uint8Array> { 48 + // Validate gzip magic bytes 49 + if (data.length < 2 || data[0] !== 0x1f || data[1] !== 0x8b) { 50 + throw new Error('Invalid gzip data: missing magic bytes'); 51 + } 52 + 53 + const buffer = Buffer.from(data); 54 + const decompressed = await gunzipAsync(buffer); 55 + return new Uint8Array(decompressed); 56 + } 57 + 58 + /** 59 + * Check if data appears to be gzip-compressed by inspecting magic bytes. 60 + * 61 + * @param data - Data to check 62 + * @returns true if data starts with gzip magic bytes (0x1f 0x8b) 63 + * 64 + * @remarks 65 + * This is a quick check that doesn't decompress the data. 66 + * Useful for detecting already-compressed data to avoid double compression. 67 + * 68 + * @example 69 + * ```typescript 70 + * if (isGzipped(data)) { 71 + * console.log('Already compressed, skipping compression'); 72 + * } else { 73 + * data = await compress(data); 74 + * } 75 + * ``` 76 + */ 77 + export function isGzipped(data: Uint8Array): boolean { 78 + return data.length >= 2 && data[0] === 0x1f && data[1] === 0x8b; 79 + } 80 + 81 + /** 82 + * Create a gzip compression transform stream. 83 + * 84 + * @returns A transform stream that compresses data passing through it 85 + * 86 + * @remarks 87 + * Use this for streaming compression of large files. 88 + * Pipe data through this stream to compress it on-the-fly. 89 + * 90 + * @example 91 + * ```typescript 92 + * const compressStream = createCompressStream(); 93 + * sourceStream.pipe(compressStream).pipe(destinationStream); 94 + * ``` 95 + */ 96 + export function createCompressStream(): Transform { 97 + return createGzip(); 98 + } 99 + 100 + /** 101 + * Create a gzip decompression transform stream. 102 + * 103 + * @returns A transform stream that decompresses data passing through it 104 + * 105 + * @remarks 106 + * Use this for streaming decompression of large files. 107 + * Pipe compressed data through this stream to decompress it on-the-fly. 108 + * 109 + * @example 110 + * ```typescript 111 + * const decompressStream = createDecompressStream(); 112 + * compressedStream.pipe(decompressStream).pipe(destinationStream); 113 + * ``` 114 + */ 115 + export function createDecompressStream(): Transform { 116 + return createGunzip(); 117 + }
+43
packages/@wispplace/tiered-storage/src/utils/glob.ts
··· 1 + /** 2 + * Simple glob pattern matching for key placement rules. 3 + * 4 + * Supports: 5 + * - `*` matches any characters except `/` 6 + * - `**` matches any characters including `/` (including empty string) 7 + * - `{a,b,c}` matches any of the alternatives 8 + * - Exact strings match exactly 9 + */ 10 + export function matchGlob(pattern: string, key: string): boolean { 11 + // Handle exact match 12 + if (!pattern.includes('*') && !pattern.includes('{')) { 13 + return pattern === key; 14 + } 15 + 16 + // Escape regex special chars (except * and {}) 17 + let regex = pattern.replace(/[.+^$|\\()[\]]/g, '\\$&'); 18 + 19 + // Handle {a,b,c} alternation 20 + regex = regex.replace( 21 + /\{([^}]+)\}/g, 22 + (_match: string, alts: string) => `(${alts.split(',').join('|')})`, 23 + ); 24 + 25 + // Use placeholder to avoid double-processing 26 + const DOUBLE = '\x00DOUBLE\x00'; 27 + const SINGLE = '\x00SINGLE\x00'; 28 + 29 + // Mark ** and * with placeholders 30 + regex = regex.replace(/\*\*/g, DOUBLE); 31 + regex = regex.replace(/\*/g, SINGLE); 32 + 33 + // Replace placeholders with regex patterns 34 + // ** matches anything (including /) 35 + // When followed by /, it's optional (matches zero or more path segments) 36 + regex = regex 37 + .replace(new RegExp(`${DOUBLE}/`, 'g'), '(?:.*/)?') // **/ -> optional path prefix 38 + .replace(new RegExp(`/${DOUBLE}`, 'g'), '(?:/.*)?') // /** -> optional path suffix 39 + .replace(new RegExp(DOUBLE, 'g'), '.*') // ** alone -> match anything 40 + .replace(new RegExp(SINGLE, 'g'), '[^/]*'); // * -> match non-slash 41 + 42 + return new RegExp(`^${regex}$`).test(key); 43 + }
+92
packages/@wispplace/tiered-storage/src/utils/path-encoding.ts
··· 1 + /** 2 + * Encode a key to be safe for use as a filesystem path. 3 + * 4 + * @param key - The key to encode 5 + * @param encodeColons - Whether to encode colons as %3A (default: false) 6 + * @returns Filesystem-safe encoded key 7 + * 8 + * @remarks 9 + * Preserves forward slashes to create directory structure. 10 + * Encodes characters that are problematic in filenames: 11 + * - Backslash (\) → %5C 12 + * - Colon (:) → %3A (when encodeColons is true, invalid on Windows) 13 + * - Asterisk (*) → %2A 14 + * - Question mark (?) → %3F 15 + * - Quote (") → %22 16 + * - Less than (<) → %3C 17 + * - Greater than (>) → %3E 18 + * - Pipe (|) → %7C 19 + * - Percent (%) → %25 20 + * - Null byte → %00 21 + * 22 + * @example 23 + * ```typescript 24 + * const key = 'did:plc:abc123/site/index.html'; 25 + * 26 + * // Encode colons (Windows/cross-platform) 27 + * const encoded = encodeKey(key, true); 28 + * // Result: 'did%3Aplc%3Aabc123/site/index.html' 29 + * // Creates: cache/did%3Aplc%3Aabc123/site/index.html 30 + * 31 + * // Preserve colons (Unix/macOS with readable paths) 32 + * const readable = encodeKey(key, false); 33 + * // Result: 'did:plc:abc123/site/index.html' 34 + * // Creates: cache/did:plc:abc123/site/index.html 35 + * ``` 36 + */ 37 + export function encodeKey(key: string, encodeColons = false): string { 38 + let result = key 39 + .replace(/%/g, '%25') // Must be first! 40 + .replace(/\\/g, '%5C'); 41 + 42 + if (encodeColons) { 43 + result = result.replace(/:/g, '%3A'); 44 + } 45 + 46 + return result 47 + .replace(/\*/g, '%2A') 48 + .replace(/\?/g, '%3F') 49 + .replace(/"/g, '%22') 50 + .replace(/</g, '%3C') 51 + .replace(/>/g, '%3E') 52 + .replace(/\|/g, '%7C') 53 + .replace(/\0/g, '%00'); 54 + } 55 + 56 + /** 57 + * Decode a filesystem-safe key back to original form. 58 + * 59 + * @param encoded - The encoded key 60 + * @param decodeColons - Whether to decode %3A to : (default: false) 61 + * @returns Original key 62 + * 63 + * @example 64 + * ```typescript 65 + * const encoded = 'did%3Aplc%3Aabc123/site/index.html'; 66 + * 67 + * // Decode with colons 68 + * const key = decodeKey(encoded, true); 69 + * // Result: 'did:plc:abc123/site/index.html' 70 + * 71 + * // Decode without colons (already readable) 72 + * const readable = decodeKey('did:plc:abc123/site/index.html', false); 73 + * // Result: 'did:plc:abc123/site/index.html' 74 + * ``` 75 + */ 76 + export function decodeKey(encoded: string, decodeColons = false): string { 77 + let result = encoded 78 + .replace(/%5C/g, '\\') 79 + .replace(/%2A/g, '*') 80 + .replace(/%3F/g, '?') 81 + .replace(/%22/g, '"') 82 + .replace(/%3C/g, '<') 83 + .replace(/%3E/g, '>') 84 + .replace(/%7C/g, '|') 85 + .replace(/%00/g, '\0'); 86 + 87 + if (decodeColons) { 88 + result = result.replace(/%3A/g, ':'); 89 + } 90 + 91 + return result.replace(/%25/g, '%'); // Must be last! 92 + }
+46
packages/@wispplace/tiered-storage/src/utils/serialization.ts
··· 1 + /** 2 + * Default JSON serializer. 3 + * 4 + * @param data - Data to serialize (must be JSON-serializable) 5 + * @returns Serialized data as Uint8Array (UTF-8 encoded JSON) 6 + * 7 + * @remarks 8 + * This is the default serializer used if no custom serializer is provided. 9 + * Handles most JavaScript types but cannot serialize: 10 + * - Functions 11 + * - Symbols 12 + * - undefined values (they become null) 13 + * - Circular references 14 + * 15 + * @example 16 + * ```typescript 17 + * const data = { name: 'Alice', age: 30 }; 18 + * const serialized = await defaultSerialize(data); 19 + * ``` 20 + */ 21 + export async function defaultSerialize(data: unknown): Promise<Uint8Array> { 22 + const json = JSON.stringify(data); 23 + return new TextEncoder().encode(json); 24 + } 25 + 26 + /** 27 + * Default JSON deserializer. 28 + * 29 + * @param data - Serialized data (UTF-8 encoded JSON) 30 + * @returns Deserialized data 31 + * @throws SyntaxError if data is not valid JSON 32 + * 33 + * @remarks 34 + * This is the default deserializer used if no custom deserializer is provided. 35 + * Parses UTF-8 encoded JSON back to JavaScript objects. 36 + * 37 + * @example 38 + * ```typescript 39 + * const data = await defaultDeserialize(serialized); 40 + * console.log(data.name); // 'Alice' 41 + * ``` 42 + */ 43 + export async function defaultDeserialize(data: Uint8Array): Promise<unknown> { 44 + const json = new TextDecoder().decode(data); 45 + return JSON.parse(json); 46 + }
+387
packages/@wispplace/tiered-storage/test/DiskStorageTier.test.ts
··· 1 + import { describe, it, expect, afterEach } from 'vitest'; 2 + import { DiskStorageTier } from '../src/tiers/DiskStorageTier.js'; 3 + import { rm, readdir } from 'node:fs/promises'; 4 + import { existsSync } from 'node:fs'; 5 + import { join } from 'node:path'; 6 + 7 + describe('DiskStorageTier - Recursive Directory Support', () => { 8 + const testDir = './test-disk-cache'; 9 + 10 + afterEach(async () => { 11 + await rm(testDir, { recursive: true, force: true }); 12 + }); 13 + 14 + describe('Nested Directory Creation', () => { 15 + it('should create nested directories for keys with slashes', async () => { 16 + const tier = new DiskStorageTier({ directory: testDir }); 17 + 18 + const data = new TextEncoder().encode('test data'); 19 + const metadata = { 20 + key: 'did:plc:abc/site/pages/index.html', 21 + size: data.byteLength, 22 + createdAt: new Date(), 23 + lastAccessed: new Date(), 24 + accessCount: 0, 25 + compressed: false, 26 + checksum: 'abc123', 27 + }; 28 + 29 + await tier.set('did:plc:abc/site/pages/index.html', data, metadata); 30 + 31 + // Verify directory structure was created 32 + expect(existsSync(join(testDir, 'did%3Aplc%3Aabc'))).toBe(true); 33 + expect(existsSync(join(testDir, 'did%3Aplc%3Aabc/site'))).toBe(true); 34 + expect(existsSync(join(testDir, 'did%3Aplc%3Aabc/site/pages'))).toBe(true); 35 + expect(existsSync(join(testDir, 'did%3Aplc%3Aabc/site/pages/index.html'))).toBe(true); 36 + expect(existsSync(join(testDir, 'did%3Aplc%3Aabc/site/pages/index.html.meta'))).toBe( 37 + true, 38 + ); 39 + }); 40 + 41 + it('should handle multiple files in different nested directories', async () => { 42 + const tier = new DiskStorageTier({ directory: testDir }); 43 + 44 + const data = new TextEncoder().encode('test'); 45 + const createMetadata = (key: string) => ({ 46 + key, 47 + size: data.byteLength, 48 + createdAt: new Date(), 49 + lastAccessed: new Date(), 50 + accessCount: 0, 51 + compressed: false, 52 + checksum: 'abc', 53 + }); 54 + 55 + await tier.set( 56 + 'site:a/images/logo.png', 57 + data, 58 + createMetadata('site:a/images/logo.png'), 59 + ); 60 + await tier.set('site:a/css/style.css', data, createMetadata('site:a/css/style.css')); 61 + await tier.set('site:b/index.html', data, createMetadata('site:b/index.html')); 62 + 63 + expect(await tier.exists('site:a/images/logo.png')).toBe(true); 64 + expect(await tier.exists('site:a/css/style.css')).toBe(true); 65 + expect(await tier.exists('site:b/index.html')).toBe(true); 66 + }); 67 + }); 68 + 69 + describe('Recursive Listing', () => { 70 + it('should list all keys across nested directories', async () => { 71 + const tier = new DiskStorageTier({ directory: testDir }); 72 + 73 + const data = new TextEncoder().encode('test'); 74 + const createMetadata = (key: string) => ({ 75 + key, 76 + size: data.byteLength, 77 + createdAt: new Date(), 78 + lastAccessed: new Date(), 79 + accessCount: 0, 80 + compressed: false, 81 + checksum: 'abc', 82 + }); 83 + 84 + const keys = [ 85 + 'site:a/index.html', 86 + 'site:a/about.html', 87 + 'site:a/assets/logo.png', 88 + 'site:b/index.html', 89 + 'site:b/nested/deep/file.txt', 90 + ]; 91 + 92 + for (const key of keys) { 93 + await tier.set(key, data, createMetadata(key)); 94 + } 95 + 96 + const listedKeys: string[] = []; 97 + for await (const key of tier.listKeys()) { 98 + listedKeys.push(key); 99 + } 100 + 101 + expect(listedKeys.sort()).toEqual(keys.sort()); 102 + }); 103 + 104 + it('should list keys with prefix filter across directories', async () => { 105 + const tier = new DiskStorageTier({ directory: testDir }); 106 + 107 + const data = new TextEncoder().encode('test'); 108 + const createMetadata = (key: string) => ({ 109 + key, 110 + size: data.byteLength, 111 + createdAt: new Date(), 112 + lastAccessed: new Date(), 113 + accessCount: 0, 114 + compressed: false, 115 + checksum: 'abc', 116 + }); 117 + 118 + await tier.set('site:a/index.html', data, createMetadata('site:a/index.html')); 119 + await tier.set('site:a/about.html', data, createMetadata('site:a/about.html')); 120 + await tier.set('site:b/index.html', data, createMetadata('site:b/index.html')); 121 + await tier.set('user:123/profile.json', data, createMetadata('user:123/profile.json')); 122 + 123 + const siteKeys: string[] = []; 124 + for await (const key of tier.listKeys('site:')) { 125 + siteKeys.push(key); 126 + } 127 + 128 + expect(siteKeys.sort()).toEqual([ 129 + 'site:a/about.html', 130 + 'site:a/index.html', 131 + 'site:b/index.html', 132 + ]); 133 + }); 134 + 135 + it('should handle empty directories gracefully', async () => { 136 + const tier = new DiskStorageTier({ directory: testDir }); 137 + 138 + const keys: string[] = []; 139 + for await (const key of tier.listKeys()) { 140 + keys.push(key); 141 + } 142 + 143 + expect(keys).toEqual([]); 144 + }); 145 + }); 146 + 147 + describe('Recursive Stats Collection', () => { 148 + it('should calculate stats across all nested directories', async () => { 149 + const tier = new DiskStorageTier({ directory: testDir }); 150 + 151 + const data1 = new TextEncoder().encode('small'); 152 + const data2 = new TextEncoder().encode('medium content here'); 153 + const data3 = new TextEncoder().encode('x'.repeat(1000)); 154 + 155 + const createMetadata = (key: string, size: number) => ({ 156 + key, 157 + size, 158 + createdAt: new Date(), 159 + lastAccessed: new Date(), 160 + accessCount: 0, 161 + compressed: false, 162 + checksum: 'abc', 163 + }); 164 + 165 + await tier.set('a/file1.txt', data1, createMetadata('a/file1.txt', data1.byteLength)); 166 + await tier.set( 167 + 'a/b/file2.txt', 168 + data2, 169 + createMetadata('a/b/file2.txt', data2.byteLength), 170 + ); 171 + await tier.set( 172 + 'a/b/c/file3.txt', 173 + data3, 174 + createMetadata('a/b/c/file3.txt', data3.byteLength), 175 + ); 176 + 177 + const stats = await tier.getStats(); 178 + 179 + expect(stats.items).toBe(3); 180 + expect(stats.bytes).toBe(data1.byteLength + data2.byteLength + data3.byteLength); 181 + }); 182 + 183 + it('should return zero stats for empty directory', async () => { 184 + const tier = new DiskStorageTier({ directory: testDir }); 185 + 186 + const stats = await tier.getStats(); 187 + 188 + expect(stats.items).toBe(0); 189 + expect(stats.bytes).toBe(0); 190 + }); 191 + }); 192 + 193 + describe('Index Rebuilding', () => { 194 + it('should rebuild index from nested directory structure on init', async () => { 195 + const data = new TextEncoder().encode('test data'); 196 + const createMetadata = (key: string) => ({ 197 + key, 198 + size: data.byteLength, 199 + createdAt: new Date(), 200 + lastAccessed: new Date(), 201 + accessCount: 0, 202 + compressed: false, 203 + checksum: 'abc', 204 + }); 205 + 206 + // Create tier and add nested data 207 + const tier1 = new DiskStorageTier({ directory: testDir }); 208 + await tier1.set('site:a/index.html', data, createMetadata('site:a/index.html')); 209 + await tier1.set( 210 + 'site:a/nested/deep/file.txt', 211 + data, 212 + createMetadata('site:a/nested/deep/file.txt'), 213 + ); 214 + await tier1.set('site:b/page.html', data, createMetadata('site:b/page.html')); 215 + 216 + // Create new tier instance (should rebuild index from disk) 217 + const tier2 = new DiskStorageTier({ directory: testDir }); 218 + 219 + // Give it a moment to rebuild 220 + await new Promise((resolve) => setTimeout(resolve, 100)); 221 + 222 + // Verify all keys are accessible 223 + expect(await tier2.exists('site:a/index.html')).toBe(true); 224 + expect(await tier2.exists('site:a/nested/deep/file.txt')).toBe(true); 225 + expect(await tier2.exists('site:b/page.html')).toBe(true); 226 + 227 + // Verify stats are correct 228 + const stats = await tier2.getStats(); 229 + expect(stats.items).toBe(3); 230 + }); 231 + 232 + it('should handle corrupted metadata files during rebuild', async () => { 233 + const tier = new DiskStorageTier({ directory: testDir }); 234 + 235 + const data = new TextEncoder().encode('test'); 236 + const metadata = { 237 + key: 'test/key.txt', 238 + size: data.byteLength, 239 + createdAt: new Date(), 240 + lastAccessed: new Date(), 241 + accessCount: 0, 242 + compressed: false, 243 + checksum: 'abc', 244 + }; 245 + 246 + await tier.set('test/key.txt', data, metadata); 247 + 248 + // Verify directory structure 249 + const entries = await readdir(testDir, { withFileTypes: true }); 250 + expect(entries.length).toBeGreaterThan(0); 251 + 252 + // New tier instance should handle any issues gracefully 253 + const tier2 = new DiskStorageTier({ directory: testDir }); 254 + await new Promise((resolve) => setTimeout(resolve, 100)); 255 + 256 + // Should still work 257 + const stats = await tier2.getStats(); 258 + expect(stats.items).toBeGreaterThanOrEqual(0); 259 + }); 260 + }); 261 + 262 + describe('getWithMetadata Optimization', () => { 263 + it('should retrieve data and metadata from nested directories in parallel', async () => { 264 + const tier = new DiskStorageTier({ directory: testDir }); 265 + 266 + const data = new TextEncoder().encode('test data content'); 267 + const metadata = { 268 + key: 'deep/nested/path/file.json', 269 + size: data.byteLength, 270 + createdAt: new Date(), 271 + lastAccessed: new Date(), 272 + accessCount: 5, 273 + compressed: false, 274 + checksum: 'abc123', 275 + }; 276 + 277 + await tier.set('deep/nested/path/file.json', data, metadata); 278 + 279 + const result = await tier.getWithMetadata('deep/nested/path/file.json'); 280 + 281 + expect(result).not.toBeNull(); 282 + expect(result?.data).toEqual(data); 283 + expect(result?.metadata.key).toBe('deep/nested/path/file.json'); 284 + expect(result?.metadata.accessCount).toBe(5); 285 + }); 286 + }); 287 + 288 + describe('Deletion from Nested Directories', () => { 289 + it('should delete files from nested directories', async () => { 290 + const tier = new DiskStorageTier({ directory: testDir }); 291 + 292 + const data = new TextEncoder().encode('test'); 293 + const createMetadata = (key: string) => ({ 294 + key, 295 + size: data.byteLength, 296 + createdAt: new Date(), 297 + lastAccessed: new Date(), 298 + accessCount: 0, 299 + compressed: false, 300 + checksum: 'abc', 301 + }); 302 + 303 + await tier.set('a/b/c/file1.txt', data, createMetadata('a/b/c/file1.txt')); 304 + await tier.set('a/b/file2.txt', data, createMetadata('a/b/file2.txt')); 305 + 306 + expect(await tier.exists('a/b/c/file1.txt')).toBe(true); 307 + 308 + await tier.delete('a/b/c/file1.txt'); 309 + 310 + expect(await tier.exists('a/b/c/file1.txt')).toBe(false); 311 + expect(await tier.exists('a/b/file2.txt')).toBe(true); 312 + }); 313 + 314 + it('should delete multiple files across nested directories', async () => { 315 + const tier = new DiskStorageTier({ directory: testDir }); 316 + 317 + const data = new TextEncoder().encode('test'); 318 + const createMetadata = (key: string) => ({ 319 + key, 320 + size: data.byteLength, 321 + createdAt: new Date(), 322 + lastAccessed: new Date(), 323 + accessCount: 0, 324 + compressed: false, 325 + checksum: 'abc', 326 + }); 327 + 328 + const keys = ['site:a/index.html', 'site:a/nested/page.html', 'site:b/index.html']; 329 + 330 + for (const key of keys) { 331 + await tier.set(key, data, createMetadata(key)); 332 + } 333 + 334 + await tier.deleteMany(keys); 335 + 336 + for (const key of keys) { 337 + expect(await tier.exists(key)).toBe(false); 338 + } 339 + }); 340 + }); 341 + 342 + describe('Edge Cases', () => { 343 + it('should handle keys with many nested levels', async () => { 344 + const tier = new DiskStorageTier({ directory: testDir }); 345 + 346 + const data = new TextEncoder().encode('deep'); 347 + const deepKey = 'a/b/c/d/e/f/g/h/i/j/k/file.txt'; 348 + const metadata = { 349 + key: deepKey, 350 + size: data.byteLength, 351 + createdAt: new Date(), 352 + lastAccessed: new Date(), 353 + accessCount: 0, 354 + compressed: false, 355 + checksum: 'abc', 356 + }; 357 + 358 + await tier.set(deepKey, data, metadata); 359 + 360 + expect(await tier.exists(deepKey)).toBe(true); 361 + 362 + const retrieved = await tier.get(deepKey); 363 + expect(retrieved).toEqual(data); 364 + }); 365 + 366 + it('should handle keys with special characters', async () => { 367 + const tier = new DiskStorageTier({ directory: testDir }); 368 + 369 + const data = new TextEncoder().encode('test'); 370 + const metadata = { 371 + key: 'site:abc/file[1].txt', 372 + size: data.byteLength, 373 + createdAt: new Date(), 374 + lastAccessed: new Date(), 375 + accessCount: 0, 376 + compressed: false, 377 + checksum: 'abc', 378 + }; 379 + 380 + await tier.set('site:abc/file[1].txt', data, metadata); 381 + 382 + expect(await tier.exists('site:abc/file[1].txt')).toBe(true); 383 + const retrieved = await tier.get('site:abc/file[1].txt'); 384 + expect(retrieved).toEqual(data); 385 + }); 386 + }); 387 + });
+631
packages/@wispplace/tiered-storage/test/TieredStorage.test.ts
··· 1 + import { describe, it, expect, afterEach } from 'vitest'; 2 + import { TieredStorage } from '../src/TieredStorage.js'; 3 + import { MemoryStorageTier } from '../src/tiers/MemoryStorageTier.js'; 4 + import { DiskStorageTier } from '../src/tiers/DiskStorageTier.js'; 5 + import { rm } from 'node:fs/promises'; 6 + 7 + describe('TieredStorage', () => { 8 + const testDir = './test-cache'; 9 + 10 + afterEach(async () => { 11 + await rm(testDir, { recursive: true, force: true }); 12 + }); 13 + 14 + describe('Basic Operations', () => { 15 + it('should store and retrieve data', async () => { 16 + const storage = new TieredStorage({ 17 + tiers: { 18 + hot: new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }), 19 + warm: new DiskStorageTier({ directory: `${testDir}/warm` }), 20 + cold: new DiskStorageTier({ directory: `${testDir}/cold` }), 21 + }, 22 + }); 23 + 24 + await storage.set('test-key', { message: 'Hello, world!' }); 25 + const result = await storage.get('test-key'); 26 + 27 + expect(result).toEqual({ message: 'Hello, world!' }); 28 + }); 29 + 30 + it('should return null for non-existent key', async () => { 31 + const storage = new TieredStorage({ 32 + tiers: { 33 + cold: new DiskStorageTier({ directory: `${testDir}/cold` }), 34 + }, 35 + }); 36 + 37 + const result = await storage.get('non-existent'); 38 + expect(result).toBeNull(); 39 + }); 40 + 41 + it('should delete data from all tiers', async () => { 42 + const storage = new TieredStorage({ 43 + tiers: { 44 + hot: new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }), 45 + warm: new DiskStorageTier({ directory: `${testDir}/warm` }), 46 + cold: new DiskStorageTier({ directory: `${testDir}/cold` }), 47 + }, 48 + }); 49 + 50 + await storage.set('test-key', { data: 'test' }); 51 + await storage.delete('test-key'); 52 + const result = await storage.get('test-key'); 53 + 54 + expect(result).toBeNull(); 55 + }); 56 + 57 + it('should check if key exists', async () => { 58 + const storage = new TieredStorage({ 59 + tiers: { 60 + cold: new DiskStorageTier({ directory: `${testDir}/cold` }), 61 + }, 62 + }); 63 + 64 + await storage.set('test-key', { data: 'test' }); 65 + 66 + expect(await storage.exists('test-key')).toBe(true); 67 + expect(await storage.exists('non-existent')).toBe(false); 68 + }); 69 + }); 70 + 71 + describe('Cascading Write', () => { 72 + it('should write to all configured tiers', async () => { 73 + const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 74 + const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 75 + const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 76 + 77 + const storage = new TieredStorage({ 78 + tiers: { hot, warm, cold }, 79 + }); 80 + 81 + await storage.set('test-key', { data: 'test' }); 82 + 83 + // Verify data exists in all tiers 84 + expect(await hot.exists('test-key')).toBe(true); 85 + expect(await warm.exists('test-key')).toBe(true); 86 + expect(await cold.exists('test-key')).toBe(true); 87 + }); 88 + 89 + it('should skip tiers when specified', async () => { 90 + const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 91 + const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 92 + const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 93 + 94 + const storage = new TieredStorage({ 95 + tiers: { hot, warm, cold }, 96 + }); 97 + 98 + // Skip hot tier 99 + await storage.set('test-key', { data: 'test' }, { skipTiers: ['hot'] }); 100 + 101 + expect(await hot.exists('test-key')).toBe(false); 102 + expect(await warm.exists('test-key')).toBe(true); 103 + expect(await cold.exists('test-key')).toBe(true); 104 + }); 105 + 106 + it('should write only to specified tiers with onlyTiers', async () => { 107 + const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 108 + const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 109 + const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 110 + 111 + const storage = new TieredStorage({ 112 + tiers: { hot, warm, cold }, 113 + }); 114 + 115 + // Write only to cold tier 116 + await storage.set('test-key', { data: 'test' }, { onlyTiers: ['cold'] }); 117 + 118 + expect(await hot.exists('test-key')).toBe(false); 119 + expect(await warm.exists('test-key')).toBe(false); 120 + expect(await cold.exists('test-key')).toBe(true); 121 + }); 122 + 123 + it('should write only to warm and cold with onlyTiers', async () => { 124 + const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 125 + const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 126 + const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 127 + 128 + const storage = new TieredStorage({ 129 + tiers: { hot, warm, cold }, 130 + }); 131 + 132 + // Write to warm and cold only 133 + await storage.set('test-key', { data: 'test' }, { onlyTiers: ['warm', 'cold'] }); 134 + 135 + expect(await hot.exists('test-key')).toBe(false); 136 + expect(await warm.exists('test-key')).toBe(true); 137 + expect(await cold.exists('test-key')).toBe(true); 138 + }); 139 + 140 + it('should allow onlyTiers to override placement rules', async () => { 141 + const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 142 + const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 143 + const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 144 + 145 + const storage = new TieredStorage({ 146 + tiers: { hot, warm, cold }, 147 + placementRules: [{ pattern: '**', tiers: ['hot', 'warm', 'cold'] }], 148 + }); 149 + 150 + // onlyTiers should override the rule 151 + await storage.set('test-key', { data: 'test' }, { onlyTiers: ['cold'] }); 152 + 153 + expect(await hot.exists('test-key')).toBe(false); 154 + expect(await warm.exists('test-key')).toBe(false); 155 + expect(await cold.exists('test-key')).toBe(true); 156 + }); 157 + 158 + it('should prioritize onlyTiers over skipTiers if both are provided', async () => { 159 + const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 160 + const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 161 + const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 162 + 163 + const storage = new TieredStorage({ 164 + tiers: { hot, warm, cold }, 165 + }); 166 + 167 + // Both onlyTiers and skipTiers - onlyTiers should win 168 + await storage.set('test-key', { data: 'test' }, { 169 + onlyTiers: ['cold'], 170 + skipTiers: ['warm'], // This should be ignored 171 + }); 172 + 173 + expect(await hot.exists('test-key')).toBe(false); 174 + expect(await warm.exists('test-key')).toBe(false); 175 + expect(await cold.exists('test-key')).toBe(true); 176 + }); 177 + }); 178 + 179 + describe('Bubbling Read', () => { 180 + it('should read from hot tier first', async () => { 181 + const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 182 + const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 183 + const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 184 + 185 + const storage = new TieredStorage({ 186 + tiers: { hot, warm, cold }, 187 + }); 188 + 189 + await storage.set('test-key', { data: 'test' }); 190 + const result = await storage.getWithMetadata('test-key'); 191 + 192 + expect(result?.source).toBe('hot'); 193 + expect(result?.data).toEqual({ data: 'test' }); 194 + }); 195 + 196 + it('should fall back to warm tier on hot miss', async () => { 197 + const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 198 + const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 199 + const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 200 + 201 + const storage = new TieredStorage({ 202 + tiers: { hot, warm, cold }, 203 + }); 204 + 205 + // Write to warm and cold, skip hot 206 + await storage.set('test-key', { data: 'test' }, { skipTiers: ['hot'] }); 207 + 208 + const result = await storage.getWithMetadata('test-key'); 209 + 210 + expect(result?.source).toBe('warm'); 211 + expect(result?.data).toEqual({ data: 'test' }); 212 + }); 213 + 214 + it('should fall back to cold tier on hot and warm miss', async () => { 215 + const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 216 + const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 217 + 218 + const storage = new TieredStorage({ 219 + tiers: { hot, cold }, 220 + }); 221 + 222 + // Write only to cold 223 + await cold.set('test-key', new TextEncoder().encode(JSON.stringify({ data: 'test' })), { 224 + key: 'test-key', 225 + size: 100, 226 + createdAt: new Date(), 227 + lastAccessed: new Date(), 228 + accessCount: 0, 229 + compressed: false, 230 + checksum: 'abc123', 231 + }); 232 + 233 + const result = await storage.getWithMetadata('test-key'); 234 + 235 + expect(result?.source).toBe('cold'); 236 + expect(result?.data).toEqual({ data: 'test' }); 237 + }); 238 + }); 239 + 240 + describe('Promotion Strategy', () => { 241 + it('should eagerly promote data to upper tiers', async () => { 242 + const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 243 + const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 244 + const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 245 + 246 + const storage = new TieredStorage({ 247 + tiers: { hot, warm, cold }, 248 + promotionStrategy: 'eager', 249 + }); 250 + 251 + // Write only to cold 252 + await cold.set('test-key', new TextEncoder().encode(JSON.stringify({ data: 'test' })), { 253 + key: 'test-key', 254 + size: 100, 255 + createdAt: new Date(), 256 + lastAccessed: new Date(), 257 + accessCount: 0, 258 + compressed: false, 259 + checksum: 'abc123', 260 + }); 261 + 262 + // Read should promote to hot and warm 263 + await storage.get('test-key'); 264 + 265 + expect(await hot.exists('test-key')).toBe(true); 266 + expect(await warm.exists('test-key')).toBe(true); 267 + }); 268 + 269 + it('should lazily promote data (not automatic)', async () => { 270 + const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 271 + const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 272 + const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 273 + 274 + const storage = new TieredStorage({ 275 + tiers: { hot, warm, cold }, 276 + promotionStrategy: 'lazy', 277 + }); 278 + 279 + // Write only to cold 280 + await cold.set('test-key', new TextEncoder().encode(JSON.stringify({ data: 'test' })), { 281 + key: 'test-key', 282 + size: 100, 283 + createdAt: new Date(), 284 + lastAccessed: new Date(), 285 + accessCount: 0, 286 + compressed: false, 287 + checksum: 'abc123', 288 + }); 289 + 290 + // Read should NOT promote to hot and warm 291 + await storage.get('test-key'); 292 + 293 + expect(await hot.exists('test-key')).toBe(false); 294 + expect(await warm.exists('test-key')).toBe(false); 295 + }); 296 + }); 297 + 298 + describe('TTL Management', () => { 299 + it('should expire data after TTL', async () => { 300 + const storage = new TieredStorage({ 301 + tiers: { 302 + cold: new DiskStorageTier({ directory: `${testDir}/cold` }), 303 + }, 304 + }); 305 + 306 + // Set with 100ms TTL 307 + await storage.set('test-key', { data: 'test' }, { ttl: 100 }); 308 + 309 + // Should exist immediately 310 + expect(await storage.get('test-key')).toEqual({ data: 'test' }); 311 + 312 + // Wait for expiration 313 + await new Promise((resolve) => setTimeout(resolve, 150)); 314 + 315 + // Should be null after expiration 316 + expect(await storage.get('test-key')).toBeNull(); 317 + }); 318 + 319 + it('should renew TTL with touch', async () => { 320 + const storage = new TieredStorage({ 321 + tiers: { 322 + cold: new DiskStorageTier({ directory: `${testDir}/cold` }), 323 + }, 324 + defaultTTL: 100, 325 + }); 326 + 327 + await storage.set('test-key', { data: 'test' }); 328 + 329 + // Wait 50ms 330 + await new Promise((resolve) => setTimeout(resolve, 50)); 331 + 332 + // Renew TTL 333 + await storage.touch('test-key', 200); 334 + 335 + // Wait another 100ms (would have expired without touch) 336 + await new Promise((resolve) => setTimeout(resolve, 100)); 337 + 338 + // Should still exist 339 + expect(await storage.get('test-key')).toEqual({ data: 'test' }); 340 + }); 341 + }); 342 + 343 + describe('Prefix Invalidation', () => { 344 + it('should invalidate all keys with prefix', async () => { 345 + const storage = new TieredStorage({ 346 + tiers: { 347 + hot: new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }), 348 + cold: new DiskStorageTier({ directory: `${testDir}/cold` }), 349 + }, 350 + }); 351 + 352 + await storage.set('user:123', { name: 'Alice' }); 353 + await storage.set('user:456', { name: 'Bob' }); 354 + await storage.set('post:789', { title: 'Test' }); 355 + 356 + const deleted = await storage.invalidate('user:'); 357 + 358 + expect(deleted).toBe(2); 359 + expect(await storage.exists('user:123')).toBe(false); 360 + expect(await storage.exists('user:456')).toBe(false); 361 + expect(await storage.exists('post:789')).toBe(true); 362 + }); 363 + }); 364 + 365 + describe('Compression', () => { 366 + it('should compress data when enabled', async () => { 367 + const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 368 + 369 + const storage = new TieredStorage({ 370 + tiers: { cold }, 371 + compression: true, 372 + }); 373 + 374 + const largeData = { data: 'x'.repeat(10000) }; 375 + const result = await storage.set('test-key', largeData); 376 + 377 + // Check that compressed flag is set 378 + expect(result.metadata.compressed).toBe(true); 379 + 380 + // Verify data can be retrieved correctly 381 + const retrieved = await storage.get('test-key'); 382 + expect(retrieved).toEqual(largeData); 383 + }); 384 + }); 385 + 386 + describe('Bootstrap', () => { 387 + it('should bootstrap hot from warm', async () => { 388 + const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 389 + const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 390 + const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 391 + 392 + const storage = new TieredStorage({ 393 + tiers: { hot, warm, cold }, 394 + }); 395 + 396 + // Write some data 397 + await storage.set('key1', { data: '1' }); 398 + await storage.set('key2', { data: '2' }); 399 + await storage.set('key3', { data: '3' }); 400 + 401 + // Clear hot tier 402 + await hot.clear(); 403 + 404 + // Bootstrap hot from warm 405 + const loaded = await storage.bootstrapHot(); 406 + 407 + expect(loaded).toBe(3); 408 + expect(await hot.exists('key1')).toBe(true); 409 + expect(await hot.exists('key2')).toBe(true); 410 + expect(await hot.exists('key3')).toBe(true); 411 + }); 412 + 413 + it('should bootstrap warm from cold', async () => { 414 + const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 415 + const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 416 + 417 + const storage = new TieredStorage({ 418 + tiers: { warm, cold }, 419 + }); 420 + 421 + // Write directly to cold 422 + await cold.set('key1', new TextEncoder().encode(JSON.stringify({ data: '1' })), { 423 + key: 'key1', 424 + size: 100, 425 + createdAt: new Date(), 426 + lastAccessed: new Date(), 427 + accessCount: 0, 428 + compressed: false, 429 + checksum: 'abc', 430 + }); 431 + 432 + // Bootstrap warm from cold 433 + const loaded = await storage.bootstrapWarm({ limit: 10 }); 434 + 435 + expect(loaded).toBe(1); 436 + expect(await warm.exists('key1')).toBe(true); 437 + }); 438 + }); 439 + 440 + describe('Statistics', () => { 441 + it('should return statistics for all tiers', async () => { 442 + const storage = new TieredStorage({ 443 + tiers: { 444 + hot: new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }), 445 + warm: new DiskStorageTier({ directory: `${testDir}/warm` }), 446 + cold: new DiskStorageTier({ directory: `${testDir}/cold` }), 447 + }, 448 + }); 449 + 450 + await storage.set('key1', { data: 'test1' }); 451 + await storage.set('key2', { data: 'test2' }); 452 + 453 + const stats = await storage.getStats(); 454 + 455 + expect(stats.cold.items).toBe(2); 456 + expect(stats.warm?.items).toBe(2); 457 + expect(stats.hot?.items).toBe(2); 458 + }); 459 + }); 460 + 461 + describe('Placement Rules', () => { 462 + it('should place index.html in all tiers based on rule', async () => { 463 + const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 464 + const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 465 + const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 466 + 467 + const storage = new TieredStorage({ 468 + tiers: { hot, warm, cold }, 469 + placementRules: [ 470 + { pattern: '**/index.html', tiers: ['hot', 'warm', 'cold'] }, 471 + { pattern: '**', tiers: ['warm', 'cold'] }, 472 + ], 473 + }); 474 + 475 + await storage.set('site:abc/index.html', { content: 'hello' }); 476 + 477 + expect(await hot.exists('site:abc/index.html')).toBe(true); 478 + expect(await warm.exists('site:abc/index.html')).toBe(true); 479 + expect(await cold.exists('site:abc/index.html')).toBe(true); 480 + }); 481 + 482 + it('should skip hot tier for non-matching files', async () => { 483 + const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 484 + const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 485 + const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 486 + 487 + const storage = new TieredStorage({ 488 + tiers: { hot, warm, cold }, 489 + placementRules: [ 490 + { pattern: '**/index.html', tiers: ['hot', 'warm', 'cold'] }, 491 + { pattern: '**', tiers: ['warm', 'cold'] }, 492 + ], 493 + }); 494 + 495 + await storage.set('site:abc/about.html', { content: 'about' }); 496 + 497 + expect(await hot.exists('site:abc/about.html')).toBe(false); 498 + expect(await warm.exists('site:abc/about.html')).toBe(true); 499 + expect(await cold.exists('site:abc/about.html')).toBe(true); 500 + }); 501 + 502 + it('should match directory patterns', async () => { 503 + const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 504 + const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 505 + const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 506 + 507 + const storage = new TieredStorage({ 508 + tiers: { hot, warm, cold }, 509 + placementRules: [ 510 + { pattern: 'assets/**', tiers: ['warm', 'cold'] }, 511 + { pattern: '**', tiers: ['hot', 'warm', 'cold'] }, 512 + ], 513 + }); 514 + 515 + await storage.set('assets/images/logo.png', { data: 'png' }); 516 + await storage.set('index.html', { data: 'html' }); 517 + 518 + // assets/** should skip hot 519 + expect(await hot.exists('assets/images/logo.png')).toBe(false); 520 + expect(await warm.exists('assets/images/logo.png')).toBe(true); 521 + 522 + // everything else goes to all tiers 523 + expect(await hot.exists('index.html')).toBe(true); 524 + }); 525 + 526 + it('should match file extension patterns', async () => { 527 + const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 528 + const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 529 + const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 530 + 531 + const storage = new TieredStorage({ 532 + tiers: { hot, warm, cold }, 533 + placementRules: [ 534 + { pattern: '**/*.{jpg,png,gif,mp4}', tiers: ['warm', 'cold'] }, 535 + { pattern: '**', tiers: ['hot', 'warm', 'cold'] }, 536 + ], 537 + }); 538 + 539 + await storage.set('site/hero.png', { data: 'image' }); 540 + await storage.set('site/video.mp4', { data: 'video' }); 541 + await storage.set('site/index.html', { data: 'html' }); 542 + 543 + // Images and video skip hot 544 + expect(await hot.exists('site/hero.png')).toBe(false); 545 + expect(await hot.exists('site/video.mp4')).toBe(false); 546 + 547 + // HTML goes everywhere 548 + expect(await hot.exists('site/index.html')).toBe(true); 549 + }); 550 + 551 + it('should use first matching rule', async () => { 552 + const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 553 + const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 554 + const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 555 + 556 + const storage = new TieredStorage({ 557 + tiers: { hot, warm, cold }, 558 + placementRules: [ 559 + // Specific rule first 560 + { pattern: 'assets/critical.css', tiers: ['hot', 'warm', 'cold'] }, 561 + // General rule second 562 + { pattern: 'assets/**', tiers: ['warm', 'cold'] }, 563 + { pattern: '**', tiers: ['warm', 'cold'] }, 564 + ], 565 + }); 566 + 567 + await storage.set('assets/critical.css', { data: 'css' }); 568 + await storage.set('assets/style.css', { data: 'css' }); 569 + 570 + // critical.css matches first rule -> hot 571 + expect(await hot.exists('assets/critical.css')).toBe(true); 572 + 573 + // style.css matches second rule -> no hot 574 + expect(await hot.exists('assets/style.css')).toBe(false); 575 + }); 576 + 577 + it('should allow skipTiers to override placement rules', async () => { 578 + const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 579 + const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 580 + const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 581 + 582 + const storage = new TieredStorage({ 583 + tiers: { hot, warm, cold }, 584 + placementRules: [{ pattern: '**', tiers: ['hot', 'warm', 'cold'] }], 585 + }); 586 + 587 + // Explicit skipTiers should override the rule 588 + await storage.set('large-file.bin', { data: 'big' }, { skipTiers: ['hot'] }); 589 + 590 + expect(await hot.exists('large-file.bin')).toBe(false); 591 + expect(await warm.exists('large-file.bin')).toBe(true); 592 + expect(await cold.exists('large-file.bin')).toBe(true); 593 + }); 594 + 595 + it('should always include cold tier even if not in rule', async () => { 596 + const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 597 + const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 598 + const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 599 + 600 + const storage = new TieredStorage({ 601 + tiers: { hot, warm, cold }, 602 + placementRules: [ 603 + // Rule doesn't include cold (should be auto-added) 604 + { pattern: '**', tiers: ['hot', 'warm'] }, 605 + ], 606 + }); 607 + 608 + await storage.set('test-key', { data: 'test' }); 609 + 610 + expect(await cold.exists('test-key')).toBe(true); 611 + }); 612 + 613 + it('should write to all tiers when no rules match', async () => { 614 + const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 615 + const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 616 + const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 617 + 618 + const storage = new TieredStorage({ 619 + tiers: { hot, warm, cold }, 620 + placementRules: [{ pattern: 'specific-pattern-only', tiers: ['warm', 'cold'] }], 621 + }); 622 + 623 + // This doesn't match any rule 624 + await storage.set('other-key', { data: 'test' }); 625 + 626 + expect(await hot.exists('other-key')).toBe(true); 627 + expect(await warm.exists('other-key')).toBe(true); 628 + expect(await cold.exists('other-key')).toBe(true); 629 + }); 630 + }); 631 + });
+95
packages/@wispplace/tiered-storage/test/glob.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { matchGlob } from '../src/utils/glob.js'; 3 + 4 + describe('matchGlob', () => { 5 + describe('exact matches', () => { 6 + it('should match exact strings', () => { 7 + expect(matchGlob('index.html', 'index.html')).toBe(true); 8 + expect(matchGlob('index.html', 'about.html')).toBe(false); 9 + }); 10 + 11 + it('should match paths exactly', () => { 12 + expect(matchGlob('site/index.html', 'site/index.html')).toBe(true); 13 + expect(matchGlob('site/index.html', 'other/index.html')).toBe(false); 14 + }); 15 + }); 16 + 17 + describe('* wildcard', () => { 18 + it('should match any characters except /', () => { 19 + expect(matchGlob('*.html', 'index.html')).toBe(true); 20 + expect(matchGlob('*.html', 'about.html')).toBe(true); 21 + expect(matchGlob('*.html', 'style.css')).toBe(false); 22 + }); 23 + 24 + it('should not match across path separators', () => { 25 + expect(matchGlob('*.html', 'dir/index.html')).toBe(false); 26 + }); 27 + 28 + it('should work with prefix and suffix', () => { 29 + expect(matchGlob('index.*', 'index.html')).toBe(true); 30 + expect(matchGlob('index.*', 'index.css')).toBe(true); 31 + expect(matchGlob('index.*', 'about.html')).toBe(false); 32 + }); 33 + }); 34 + 35 + describe('** wildcard', () => { 36 + it('should match any characters including /', () => { 37 + expect(matchGlob('**', 'anything')).toBe(true); 38 + expect(matchGlob('**', 'path/to/file.txt')).toBe(true); 39 + }); 40 + 41 + it('should match deeply nested paths', () => { 42 + expect(matchGlob('**/index.html', 'index.html')).toBe(true); 43 + expect(matchGlob('**/index.html', 'site/index.html')).toBe(true); 44 + expect(matchGlob('**/index.html', 'a/b/c/index.html')).toBe(true); 45 + expect(matchGlob('**/index.html', 'a/b/c/about.html')).toBe(false); 46 + }); 47 + 48 + it('should match directory prefixes', () => { 49 + expect(matchGlob('assets/**', 'assets/style.css')).toBe(true); 50 + expect(matchGlob('assets/**', 'assets/images/logo.png')).toBe(true); 51 + expect(matchGlob('assets/**', 'other/style.css')).toBe(false); 52 + }); 53 + 54 + it('should match in the middle of a path', () => { 55 + expect(matchGlob('site/**/index.html', 'site/index.html')).toBe(true); 56 + expect(matchGlob('site/**/index.html', 'site/pages/index.html')).toBe(true); 57 + expect(matchGlob('site/**/index.html', 'site/a/b/c/index.html')).toBe(true); 58 + }); 59 + }); 60 + 61 + describe('{a,b,c} alternation', () => { 62 + it('should match any of the alternatives', () => { 63 + expect(matchGlob('*.{html,css,js}', 'index.html')).toBe(true); 64 + expect(matchGlob('*.{html,css,js}', 'style.css')).toBe(true); 65 + expect(matchGlob('*.{html,css,js}', 'app.js')).toBe(true); 66 + expect(matchGlob('*.{html,css,js}', 'image.png')).toBe(false); 67 + }); 68 + 69 + it('should work with ** and alternation', () => { 70 + expect(matchGlob('**/*.{jpg,png,gif}', 'logo.png')).toBe(true); 71 + expect(matchGlob('**/*.{jpg,png,gif}', 'images/logo.png')).toBe(true); 72 + expect(matchGlob('**/*.{jpg,png,gif}', 'a/b/photo.jpg')).toBe(true); 73 + expect(matchGlob('**/*.{jpg,png,gif}', 'style.css')).toBe(false); 74 + }); 75 + }); 76 + 77 + describe('edge cases', () => { 78 + it('should handle empty strings', () => { 79 + expect(matchGlob('', '')).toBe(true); 80 + expect(matchGlob('', 'something')).toBe(false); 81 + expect(matchGlob('**', '')).toBe(true); 82 + }); 83 + 84 + it('should escape regex special characters', () => { 85 + expect(matchGlob('file.txt', 'file.txt')).toBe(true); 86 + expect(matchGlob('file.txt', 'filextxt')).toBe(false); 87 + expect(matchGlob('file[1].txt', 'file[1].txt')).toBe(true); 88 + }); 89 + 90 + it('should handle keys with colons (common in storage)', () => { 91 + expect(matchGlob('site:*/index.html', 'site:abc/index.html')).toBe(true); 92 + expect(matchGlob('site:**/index.html', 'site:abc/pages/index.html')).toBe(true); 93 + }); 94 + }); 95 + });
+598
packages/@wispplace/tiered-storage/test/streaming.test.ts
··· 1 + import { describe, it, expect, afterEach } from 'vitest'; 2 + import { TieredStorage } from '../src/TieredStorage.js'; 3 + import { MemoryStorageTier } from '../src/tiers/MemoryStorageTier.js'; 4 + import { DiskStorageTier } from '../src/tiers/DiskStorageTier.js'; 5 + import { rm } from 'node:fs/promises'; 6 + import { Readable } from 'node:stream'; 7 + import { createHash } from 'node:crypto'; 8 + 9 + describe('Streaming Operations', () => { 10 + const testDir = './test-streaming-cache'; 11 + 12 + afterEach(async () => { 13 + await rm(testDir, { recursive: true, force: true }); 14 + }); 15 + 16 + /** 17 + * Helper to create a readable stream from a string or buffer 18 + */ 19 + function createStream(data: string | Buffer): Readable { 20 + return Readable.from([Buffer.from(data)]); 21 + } 22 + 23 + /** 24 + * Helper to consume a stream and return its contents as a buffer 25 + */ 26 + async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> { 27 + const chunks: Buffer[] = []; 28 + for await (const chunk of stream) { 29 + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); 30 + } 31 + return Buffer.concat(chunks); 32 + } 33 + 34 + /** 35 + * Helper to compute SHA256 checksum of a buffer 36 + */ 37 + function computeChecksum(data: Buffer): string { 38 + return createHash('sha256').update(data).digest('hex'); 39 + } 40 + 41 + describe('DiskStorageTier Streaming', () => { 42 + it('should write and read data using streams', async () => { 43 + const tier = new DiskStorageTier({ directory: testDir }); 44 + 45 + const testData = 'Hello, streaming world! '.repeat(100); 46 + const testBuffer = Buffer.from(testData); 47 + const checksum = computeChecksum(testBuffer); 48 + 49 + const metadata = { 50 + key: 'streaming-test.txt', 51 + size: testBuffer.byteLength, 52 + createdAt: new Date(), 53 + lastAccessed: new Date(), 54 + accessCount: 0, 55 + compressed: false, 56 + checksum, 57 + }; 58 + 59 + // Write using stream 60 + await tier.setStream('streaming-test.txt', createStream(testData), metadata); 61 + 62 + // Verify file exists 63 + expect(await tier.exists('streaming-test.txt')).toBe(true); 64 + 65 + // Read using stream 66 + const result = await tier.getStream('streaming-test.txt'); 67 + expect(result).not.toBeNull(); 68 + 69 + const retrievedData = await streamToBuffer(result!.stream); 70 + expect(retrievedData.toString()).toBe(testData); 71 + expect(result!.metadata.key).toBe('streaming-test.txt'); 72 + }); 73 + 74 + it('should handle large data without memory issues', async () => { 75 + const tier = new DiskStorageTier({ directory: testDir }); 76 + 77 + // Create a 1MB chunk and repeat pattern 78 + const chunkSize = 1024 * 1024; // 1MB 79 + const chunk = Buffer.alloc(chunkSize, 'x'); 80 + 81 + const metadata = { 82 + key: 'large-file.bin', 83 + size: chunkSize, 84 + createdAt: new Date(), 85 + lastAccessed: new Date(), 86 + accessCount: 0, 87 + compressed: false, 88 + checksum: computeChecksum(chunk), 89 + }; 90 + 91 + // Write using stream 92 + await tier.setStream('large-file.bin', Readable.from([chunk]), metadata); 93 + 94 + // Read using stream 95 + const result = await tier.getStream('large-file.bin'); 96 + expect(result).not.toBeNull(); 97 + 98 + const retrievedData = await streamToBuffer(result!.stream); 99 + expect(retrievedData.length).toBe(chunkSize); 100 + expect(retrievedData.equals(chunk)).toBe(true); 101 + }); 102 + 103 + it('should return null for non-existent key', async () => { 104 + const tier = new DiskStorageTier({ directory: testDir }); 105 + 106 + const result = await tier.getStream('non-existent-key'); 107 + expect(result).toBeNull(); 108 + }); 109 + 110 + it('should handle nested directories with streaming', async () => { 111 + const tier = new DiskStorageTier({ directory: testDir }); 112 + 113 + const testData = 'nested streaming data'; 114 + const testBuffer = Buffer.from(testData); 115 + 116 + const metadata = { 117 + key: 'deep/nested/path/file.txt', 118 + size: testBuffer.byteLength, 119 + createdAt: new Date(), 120 + lastAccessed: new Date(), 121 + accessCount: 0, 122 + compressed: false, 123 + checksum: computeChecksum(testBuffer), 124 + }; 125 + 126 + await tier.setStream('deep/nested/path/file.txt', createStream(testData), metadata); 127 + 128 + const result = await tier.getStream('deep/nested/path/file.txt'); 129 + expect(result).not.toBeNull(); 130 + 131 + const retrievedData = await streamToBuffer(result!.stream); 132 + expect(retrievedData.toString()).toBe(testData); 133 + }); 134 + }); 135 + 136 + describe('MemoryStorageTier Streaming', () => { 137 + it('should write and read data using streams', async () => { 138 + const tier = new MemoryStorageTier({ maxSizeBytes: 10 * 1024 * 1024 }); 139 + 140 + const testData = 'Memory tier streaming test'; 141 + const testBuffer = Buffer.from(testData); 142 + 143 + const metadata = { 144 + key: 'memory-test.txt', 145 + size: testBuffer.byteLength, 146 + createdAt: new Date(), 147 + lastAccessed: new Date(), 148 + accessCount: 0, 149 + compressed: false, 150 + checksum: computeChecksum(testBuffer), 151 + }; 152 + 153 + // Write using stream 154 + await tier.setStream('memory-test.txt', createStream(testData), metadata); 155 + 156 + // Read using stream 157 + const result = await tier.getStream('memory-test.txt'); 158 + expect(result).not.toBeNull(); 159 + 160 + const retrievedData = await streamToBuffer(result!.stream); 161 + expect(retrievedData.toString()).toBe(testData); 162 + }); 163 + 164 + it('should return null for non-existent key', async () => { 165 + const tier = new MemoryStorageTier({ maxSizeBytes: 10 * 1024 * 1024 }); 166 + 167 + const result = await tier.getStream('non-existent-key'); 168 + expect(result).toBeNull(); 169 + }); 170 + }); 171 + 172 + describe('TieredStorage Streaming', () => { 173 + it('should store and retrieve data using streams', async () => { 174 + const storage = new TieredStorage({ 175 + tiers: { 176 + hot: new MemoryStorageTier({ maxSizeBytes: 10 * 1024 * 1024 }), 177 + warm: new DiskStorageTier({ directory: `${testDir}/warm` }), 178 + cold: new DiskStorageTier({ directory: `${testDir}/cold` }), 179 + }, 180 + }); 181 + 182 + const testData = 'TieredStorage streaming test data'; 183 + const testBuffer = Buffer.from(testData); 184 + 185 + // Write using stream 186 + const setResult = await storage.setStream('stream-key', createStream(testData), { 187 + size: testBuffer.byteLength, 188 + }); 189 + 190 + expect(setResult.key).toBe('stream-key'); 191 + expect(setResult.metadata.size).toBe(testBuffer.byteLength); 192 + // Hot tier is skipped by default for streaming 193 + expect(setResult.tiersWritten).not.toContain('hot'); 194 + expect(setResult.tiersWritten).toContain('warm'); 195 + expect(setResult.tiersWritten).toContain('cold'); 196 + 197 + // Read using stream 198 + const result = await storage.getStream('stream-key'); 199 + expect(result).not.toBeNull(); 200 + 201 + const retrievedData = await streamToBuffer(result!.stream); 202 + expect(retrievedData.toString()).toBe(testData); 203 + }); 204 + 205 + it('should compute checksum during streaming write', async () => { 206 + const storage = new TieredStorage({ 207 + tiers: { 208 + warm: new DiskStorageTier({ directory: `${testDir}/warm` }), 209 + cold: new DiskStorageTier({ directory: `${testDir}/cold` }), 210 + }, 211 + }); 212 + 213 + const testData = 'Data for checksum test'; 214 + const testBuffer = Buffer.from(testData); 215 + const expectedChecksum = computeChecksum(testBuffer); 216 + 217 + const setResult = await storage.setStream('checksum-test', createStream(testData), { 218 + size: testBuffer.byteLength, 219 + }); 220 + 221 + // Checksum should be computed and stored 222 + expect(setResult.metadata.checksum).toBe(expectedChecksum); 223 + }); 224 + 225 + it('should use provided checksum without computing', async () => { 226 + const storage = new TieredStorage({ 227 + tiers: { 228 + cold: new DiskStorageTier({ directory: `${testDir}/cold` }), 229 + }, 230 + }); 231 + 232 + const testData = 'Data with pre-computed checksum'; 233 + const testBuffer = Buffer.from(testData); 234 + const providedChecksum = 'my-custom-checksum'; 235 + 236 + const setResult = await storage.setStream('custom-checksum', createStream(testData), { 237 + size: testBuffer.byteLength, 238 + checksum: providedChecksum, 239 + }); 240 + 241 + expect(setResult.metadata.checksum).toBe(providedChecksum); 242 + }); 243 + 244 + it('should return null for non-existent key', async () => { 245 + const storage = new TieredStorage({ 246 + tiers: { 247 + cold: new DiskStorageTier({ directory: `${testDir}/cold` }), 248 + }, 249 + }); 250 + 251 + const result = await storage.getStream('non-existent'); 252 + expect(result).toBeNull(); 253 + }); 254 + 255 + it('should read from appropriate tier (warm before cold)', async () => { 256 + const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 257 + const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 258 + 259 + const storage = new TieredStorage({ 260 + tiers: { warm, cold }, 261 + }); 262 + 263 + const testData = 'Tier priority test data'; 264 + const testBuffer = Buffer.from(testData); 265 + 266 + await storage.setStream('tier-test', createStream(testData), { 267 + size: testBuffer.byteLength, 268 + }); 269 + 270 + // Both tiers should have the data 271 + expect(await warm.exists('tier-test')).toBe(true); 272 + expect(await cold.exists('tier-test')).toBe(true); 273 + 274 + // Read should come from warm (first available) 275 + const result = await storage.getStream('tier-test'); 276 + expect(result).not.toBeNull(); 277 + expect(result!.source).toBe('warm'); 278 + }); 279 + 280 + it('should fall back to cold tier when warm has no data', async () => { 281 + const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 282 + const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 283 + 284 + const storage = new TieredStorage({ 285 + tiers: { warm, cold }, 286 + }); 287 + 288 + // Write directly to cold only 289 + const testData = 'Cold tier only data'; 290 + const testBuffer = Buffer.from(testData); 291 + const metadata = { 292 + key: 'cold-only', 293 + size: testBuffer.byteLength, 294 + createdAt: new Date(), 295 + lastAccessed: new Date(), 296 + accessCount: 0, 297 + compressed: false, 298 + checksum: computeChecksum(testBuffer), 299 + }; 300 + 301 + await cold.setStream('cold-only', createStream(testData), metadata); 302 + 303 + // Read should come from cold 304 + const result = await storage.getStream('cold-only'); 305 + expect(result).not.toBeNull(); 306 + expect(result!.source).toBe('cold'); 307 + }); 308 + 309 + it('should handle TTL with metadata', async () => { 310 + const storage = new TieredStorage({ 311 + tiers: { 312 + cold: new DiskStorageTier({ directory: `${testDir}/cold` }), 313 + }, 314 + defaultTTL: 60000, // 1 minute 315 + }); 316 + 317 + const testData = 'TTL test data'; 318 + const testBuffer = Buffer.from(testData); 319 + 320 + const setResult = await storage.setStream('ttl-test', createStream(testData), { 321 + size: testBuffer.byteLength, 322 + ttl: 30000, // 30 seconds 323 + }); 324 + 325 + expect(setResult.metadata.ttl).toBeDefined(); 326 + expect(setResult.metadata.ttl!.getTime()).toBeGreaterThan(Date.now()); 327 + }); 328 + 329 + it('should include mimeType in metadata', async () => { 330 + const storage = new TieredStorage({ 331 + tiers: { 332 + cold: new DiskStorageTier({ directory: `${testDir}/cold` }), 333 + }, 334 + }); 335 + 336 + const testData = '{"message": "json data"}'; 337 + const testBuffer = Buffer.from(testData); 338 + 339 + const setResult = await storage.setStream('json-file.json', createStream(testData), { 340 + size: testBuffer.byteLength, 341 + mimeType: 'application/json', 342 + }); 343 + 344 + expect(setResult.metadata.mimeType).toBe('application/json'); 345 + }); 346 + 347 + it('should write to multiple tiers simultaneously', async () => { 348 + const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 349 + const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 350 + 351 + const storage = new TieredStorage({ 352 + tiers: { warm, cold }, 353 + }); 354 + 355 + const testData = 'Multi-tier streaming data'; 356 + const testBuffer = Buffer.from(testData); 357 + 358 + await storage.setStream('multi-tier', createStream(testData), { 359 + size: testBuffer.byteLength, 360 + }); 361 + 362 + // Verify data in both tiers 363 + const warmResult = await warm.getStream('multi-tier'); 364 + const coldResult = await cold.getStream('multi-tier'); 365 + 366 + expect(warmResult).not.toBeNull(); 367 + expect(coldResult).not.toBeNull(); 368 + 369 + const warmData = await streamToBuffer(warmResult!.stream); 370 + const coldData = await streamToBuffer(coldResult!.stream); 371 + 372 + expect(warmData.toString()).toBe(testData); 373 + expect(coldData.toString()).toBe(testData); 374 + }); 375 + 376 + it('should skip hot tier by default for streaming writes', async () => { 377 + const hot = new MemoryStorageTier({ maxSizeBytes: 10 * 1024 * 1024 }); 378 + const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 379 + const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 380 + 381 + const storage = new TieredStorage({ 382 + tiers: { hot, warm, cold }, 383 + }); 384 + 385 + const testData = 'Skip hot tier test'; 386 + const testBuffer = Buffer.from(testData); 387 + 388 + const setResult = await storage.setStream('skip-hot', createStream(testData), { 389 + size: testBuffer.byteLength, 390 + }); 391 + 392 + // Hot should be skipped by default 393 + expect(setResult.tiersWritten).not.toContain('hot'); 394 + expect(await hot.exists('skip-hot')).toBe(false); 395 + 396 + // Warm and cold should have data 397 + expect(setResult.tiersWritten).toContain('warm'); 398 + expect(setResult.tiersWritten).toContain('cold'); 399 + }); 400 + 401 + it('should allow including hot tier explicitly', async () => { 402 + const hot = new MemoryStorageTier({ maxSizeBytes: 10 * 1024 * 1024 }); 403 + const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 404 + 405 + const storage = new TieredStorage({ 406 + tiers: { hot, cold }, 407 + }); 408 + 409 + const testData = 'Include hot tier test'; 410 + const testBuffer = Buffer.from(testData); 411 + 412 + const setResult = await storage.setStream('include-hot', createStream(testData), { 413 + size: testBuffer.byteLength, 414 + skipTiers: [], // Don't skip any tiers 415 + }); 416 + 417 + // Hot should be included 418 + expect(setResult.tiersWritten).toContain('hot'); 419 + expect(await hot.exists('include-hot')).toBe(true); 420 + }); 421 + }); 422 + 423 + describe('Streaming with Compression', () => { 424 + it('should compress stream data when compression is enabled', async () => { 425 + const storage = new TieredStorage({ 426 + tiers: { 427 + warm: new DiskStorageTier({ directory: `${testDir}/warm` }), 428 + cold: new DiskStorageTier({ directory: `${testDir}/cold` }), 429 + }, 430 + compression: true, 431 + }); 432 + 433 + const testData = 'Compressible data '.repeat(100); // Repeating data compresses well 434 + const testBuffer = Buffer.from(testData); 435 + 436 + const setResult = await storage.setStream('compress-test', createStream(testData), { 437 + size: testBuffer.byteLength, 438 + }); 439 + 440 + // Metadata should indicate compression 441 + expect(setResult.metadata.compressed).toBe(true); 442 + // Checksum should be of original uncompressed data 443 + expect(setResult.metadata.checksum).toBe(computeChecksum(testBuffer)); 444 + }); 445 + 446 + it('should decompress stream data automatically on read', async () => { 447 + const storage = new TieredStorage({ 448 + tiers: { 449 + warm: new DiskStorageTier({ directory: `${testDir}/warm` }), 450 + cold: new DiskStorageTier({ directory: `${testDir}/cold` }), 451 + }, 452 + compression: true, 453 + }); 454 + 455 + const testData = 'Hello, compressed world! '.repeat(50); 456 + const testBuffer = Buffer.from(testData); 457 + 458 + await storage.setStream('decompress-test', createStream(testData), { 459 + size: testBuffer.byteLength, 460 + }); 461 + 462 + // Read back via stream 463 + const result = await storage.getStream('decompress-test'); 464 + expect(result).not.toBeNull(); 465 + expect(result!.metadata.compressed).toBe(true); 466 + 467 + // Stream should be decompressed automatically 468 + const retrievedData = await streamToBuffer(result!.stream); 469 + expect(retrievedData.toString()).toBe(testData); 470 + }); 471 + 472 + it('should not compress when compression is disabled', async () => { 473 + const storage = new TieredStorage({ 474 + tiers: { 475 + cold: new DiskStorageTier({ directory: `${testDir}/cold` }), 476 + }, 477 + compression: false, 478 + }); 479 + 480 + const testData = 'Uncompressed data '.repeat(50); 481 + const testBuffer = Buffer.from(testData); 482 + 483 + const setResult = await storage.setStream('no-compress-test', createStream(testData), { 484 + size: testBuffer.byteLength, 485 + }); 486 + 487 + expect(setResult.metadata.compressed).toBe(false); 488 + 489 + // Read back - should be exact same data 490 + const result = await storage.getStream('no-compress-test'); 491 + expect(result).not.toBeNull(); 492 + 493 + const retrievedData = await streamToBuffer(result!.stream); 494 + expect(retrievedData.toString()).toBe(testData); 495 + }); 496 + 497 + it('should preserve checksum of original data when compressed', async () => { 498 + const storage = new TieredStorage({ 499 + tiers: { 500 + cold: new DiskStorageTier({ directory: `${testDir}/cold` }), 501 + }, 502 + compression: true, 503 + }); 504 + 505 + const testData = 'Data for checksum verification '.repeat(100); 506 + const testBuffer = Buffer.from(testData); 507 + const expectedChecksum = computeChecksum(testBuffer); 508 + 509 + const setResult = await storage.setStream('checksum-compress', createStream(testData), { 510 + size: testBuffer.byteLength, 511 + }); 512 + 513 + // Checksum should match the ORIGINAL uncompressed data 514 + expect(setResult.metadata.checksum).toBe(expectedChecksum); 515 + 516 + // Read back and verify content matches 517 + const result = await storage.getStream('checksum-compress'); 518 + const retrievedData = await streamToBuffer(result!.stream); 519 + expect(computeChecksum(retrievedData)).toBe(expectedChecksum); 520 + }); 521 + }); 522 + 523 + describe('Edge Cases', () => { 524 + it('should handle empty streams', async () => { 525 + const tier = new DiskStorageTier({ directory: testDir }); 526 + 527 + const metadata = { 528 + key: 'empty-file.txt', 529 + size: 0, 530 + createdAt: new Date(), 531 + lastAccessed: new Date(), 532 + accessCount: 0, 533 + compressed: false, 534 + checksum: computeChecksum(Buffer.from('')), 535 + }; 536 + 537 + await tier.setStream('empty-file.txt', createStream(''), metadata); 538 + 539 + const result = await tier.getStream('empty-file.txt'); 540 + expect(result).not.toBeNull(); 541 + 542 + const data = await streamToBuffer(result!.stream); 543 + expect(data.length).toBe(0); 544 + }); 545 + 546 + it('should preserve binary data integrity', async () => { 547 + const tier = new DiskStorageTier({ directory: testDir }); 548 + 549 + // Create binary data with all possible byte values 550 + const binaryData = Buffer.alloc(256); 551 + for (let i = 0; i < 256; i++) { 552 + binaryData[i] = i; 553 + } 554 + 555 + const metadata = { 556 + key: 'binary-file.bin', 557 + size: binaryData.byteLength, 558 + createdAt: new Date(), 559 + lastAccessed: new Date(), 560 + accessCount: 0, 561 + compressed: false, 562 + checksum: computeChecksum(binaryData), 563 + }; 564 + 565 + await tier.setStream('binary-file.bin', Readable.from([binaryData]), metadata); 566 + 567 + const result = await tier.getStream('binary-file.bin'); 568 + expect(result).not.toBeNull(); 569 + 570 + const retrievedData = await streamToBuffer(result!.stream); 571 + expect(retrievedData.equals(binaryData)).toBe(true); 572 + }); 573 + 574 + it('should handle special characters in keys', async () => { 575 + const tier = new DiskStorageTier({ directory: testDir }); 576 + 577 + const testData = 'special key test'; 578 + const testBuffer = Buffer.from(testData); 579 + 580 + const specialKey = 'user:123/file[1].txt'; 581 + const metadata = { 582 + key: specialKey, 583 + size: testBuffer.byteLength, 584 + createdAt: new Date(), 585 + lastAccessed: new Date(), 586 + accessCount: 0, 587 + compressed: false, 588 + checksum: computeChecksum(testBuffer), 589 + }; 590 + 591 + await tier.setStream(specialKey, createStream(testData), metadata); 592 + 593 + const result = await tier.getStream(specialKey); 594 + expect(result).not.toBeNull(); 595 + expect(result!.metadata.key).toBe(specialKey); 596 + }); 597 + }); 598 + });
+8
packages/@wispplace/tiered-storage/tsconfig.eslint.json
··· 1 + { 2 + "extends": "./tsconfig.json", 3 + "compilerOptions": { 4 + "noEmit": true 5 + }, 6 + "include": ["src/**/*", "test/**/*"], 7 + "exclude": ["node_modules", "dist"] 8 + }
+33
packages/@wispplace/tiered-storage/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2022", 4 + "module": "ESNext", 5 + "moduleResolution": "node", 6 + "allowSyntheticDefaultImports": true, 7 + "esModuleInterop": true, 8 + "allowJs": true, 9 + "strict": true, 10 + "noImplicitAny": true, 11 + "strictNullChecks": true, 12 + "strictFunctionTypes": true, 13 + "strictBindCallApply": true, 14 + "strictPropertyInitialization": true, 15 + "noImplicitReturns": true, 16 + "noImplicitThis": true, 17 + "noUncheckedIndexedAccess": true, 18 + "noImplicitOverride": true, 19 + "exactOptionalPropertyTypes": true, 20 + "noPropertyAccessFromIndexSignature": false, 21 + "declaration": true, 22 + "declarationMap": true, 23 + "sourceMap": true, 24 + "outDir": "./dist", 25 + "rootDir": "./src", 26 + "removeComments": false, 27 + "skipLibCheck": true, 28 + "forceConsistentCasingInFileNames": true, 29 + "resolveJsonModule": true 30 + }, 31 + "include": ["src/**/*"], 32 + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] 33 + }