feat(storage): storage port and minio adapter with forbid-overwrite semantics

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-17 20:25:18 +08:00
parent 59464bd73d
commit 37f4b3e11f
10 changed files with 748 additions and 3 deletions
+35
View File
@@ -0,0 +1,35 @@
/** @type {import('jest').Config} */
// packages/storage/jest.config.cjs —— 真实 MinIO 集成测试(*.spec.ts,compose 起
// photo-live-minio-1)。ts-jest 保持类型检查模式(非 isolatedModules)。
// env 约定:setupFiles 经 test/load-env.ts 向上定位仓库根 .env(唯一 env 文件)。
// @photo/contracts 为 ESM 包且是本包运行时依赖:moduleNameMapper 指向其 TS 源码,
// 由 ts-jest 以 CJS 编译(.js -> $1 映射同 packages/contracts/jest.config.cjs),
// 与 services/api/jest.config.ts 的消费方式一致。
module.exports = {
testEnvironment: 'node',
rootDir: '.',
setupFiles: ['./test/load-env.ts'],
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
tsconfig: {
module: 'commonjs',
moduleResolution: 'node10',
target: 'es2022',
lib: ['es2022'],
strict: true,
esModuleInterop: true,
skipLibCheck: true,
types: ['node', 'jest'],
},
},
],
},
moduleNameMapper: {
'^@photo/contracts$': '<rootDir>/../../packages/contracts/src/index.ts',
'^(\\.{1,2}/.*)\\.js$': '$1',
},
testMatch: ['<rootDir>/test/**/*.spec.ts'],
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
};
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@photo/storage",
"version": "0.1.0",
"private": true,
"description": "StoragePort 抽象 + MinIO 适配器(presigned PUT/GET、head、流式读取、禁止覆盖语义的 copyIfAbsent;P5/G1 生产 OSS 适配器实现同一端口)",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./package.json": "./package.json"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit",
"test": "jest",
"lint": "eslint ."
},
"dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
"@aws-sdk/s3-request-presigner": "^3.700.0",
"@photo/contracts": "workspace:*"
},
"devDependencies": {
"@types/jest": "^29.5.13",
"@types/node": "^22.10.0",
"dotenv": "^16.4.5",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"typescript": "^5.6.0"
}
}
+16
View File
@@ -0,0 +1,16 @@
// packages/storage/src/errors.ts —— 存储层错误:携带契约错误码(如
// UPLOAD_VERIFY_FAILED),api 全局异常过滤器据此映射为错误 envelope。
import { ErrorCodes, type ErrorCode } from '@photo/contracts';
export class StorageError extends Error {
/** 契约稳定错误码(packages/contracts/src/errors.ts),非存储实现细节。 */
readonly code: ErrorCode;
constructor(code: ErrorCode, message: string, options?: { cause?: unknown }) {
super(message, options);
this.name = 'StorageError';
this.code = code;
}
}
export { ErrorCodes };
+32
View File
@@ -0,0 +1,32 @@
// packages/storage/src/index.ts —— 存储包统一出口:端口类型 + MinIO 适配器 + 错误 +
// env 工厂。api(Task 18 presign/verify)与 worker(Task 20 variants put/get)经
// `createStorageFromEnv()` 构造实例,测试/本地指向 MinIO,生产指向 OSS。
// commonjs 包(消费方 api/worker 均为 CJS);@photo/contracts 为 ESM dist,运行时经
// Node require(esm) 消费(Node >= 22.12,见根 engines)。
import { MinioStorageAdapter, type MinioStorageConfig } from './minio.adapter';
export { MinioStorageAdapter } from './minio.adapter';
export type { MinioStorageConfig } from './minio.adapter';
export { StorageError, ErrorCodes } from './errors';
export type { StoragePort, UploadCredential } from './port';
/**
* 从 S3_* 环境变量构造 MinIO/S3 兼容适配器(仓库根 .env 经 dotenv-cli 或
* test/load-env.ts 注入)。任一变量缺失即抛错,禁止静默回退默认值。
*/
export function createStorageFromEnv(): MinioStorageAdapter {
const config = {
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION,
accessKey: process.env.S3_ACCESS_KEY,
secretKey: process.env.S3_SECRET_KEY,
bucket: process.env.S3_BUCKET,
} satisfies Record<keyof MinioStorageConfig, string | undefined>;
const missing = Object.entries(config)
.filter(([, value]) => !value)
.map(([key]) => `S3_${key.toUpperCase()}`);
if (missing.length > 0) {
throw new Error(`createStorageFromEnv: 缺少环境变量 ${missing.join(', ')}(S3_* 配置见仓库根 .env)`);
}
return new MinioStorageAdapter(config as MinioStorageConfig);
}
+142
View File
@@ -0,0 +1,142 @@
// packages/storage/src/minio.adapter.ts —— StoragePort 的 MinIO 实现(S3 兼容协议,
// AWS SDK v3 + path-style)。生产 OSS 适配器(P5/G1)实现同端口,行为契约以此处注释为准。
import {
CopyObjectCommand,
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Readable } from 'node:stream';
import { ErrorCodes, StorageError } from './errors';
import type { StoragePort, UploadCredential } from './port';
export interface MinioStorageConfig {
endpoint: string;
region: string;
accessKey: string;
secretKey: string;
bucket: string;
}
/** S3 错误统一判定:HeadObject 对缺失键返回 404/NotFound(无 body 错误码),GetObject 为 NoSuchKey。 */
function isNotFound(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false;
const e = error as { name?: string; $metadata?: { httpStatusCode?: number } };
return e.$metadata?.httpStatusCode === 404 || e.name === 'NotFound' || e.name === 'NoSuchKey';
}
export class MinioStorageAdapter implements StoragePort {
private readonly client: S3Client;
private readonly bucket: string;
constructor(config: MinioStorageConfig) {
const missing = (Object.keys(config) as Array<keyof MinioStorageConfig>).filter((k) => !config[k]);
if (missing.length > 0) {
throw new Error(`MinioStorageAdapter: 存储配置缺失 ${missing.join(', ')}(S3_* 环境变量见仓库根 .env)`);
}
this.bucket = config.bucket;
this.client = new S3Client({
endpoint: config.endpoint,
region: config.region,
credentials: { accessKeyId: config.accessKey, secretAccessKey: config.secretKey },
forcePathStyle: true,
});
}
/**
* 预签名上传。S3 预签名无法对 Content-Length 签名(SigV4 规范排除该头),
* bytes 参数仅作为端口契约保留:字节数核验发生在上传完成后的 head/verify 阶段。
* Content-Type 经 PutObjectCommand 的 ContentType 参数参与签名,客户端必须
* 原样携带返回的 headers,否则 403 SignatureDoesNotMatch。
*/
async presignPut(objectKey: string, bytes: number, contentType: string, ttlSeconds: number): Promise<UploadCredential> {
void bytes; // 见上:签名不含 Content-Length,大小核验由 verify 阶段负责
const url = await getSignedUrl(
this.client,
new PutObjectCommand({ Bucket: this.bucket, Key: objectKey, ContentType: contentType }),
{ expiresIn: ttlSeconds },
);
return {
mode: 'presigned',
url,
method: 'PUT',
headers: { 'Content-Type': contentType },
expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(),
};
}
async presignGet(objectKey: string, ttlSeconds: number): Promise<string> {
return getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: objectKey }), {
expiresIn: ttlSeconds,
});
}
async head(objectKey: string): Promise<{ bytes: number; etag: string } | null> {
try {
const out = await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: objectKey }));
return { bytes: out.ContentLength ?? 0, etag: out.ETag ?? '' };
} catch (error) {
if (isNotFound(error)) return null;
throw error;
}
}
async getStream(objectKey: string): Promise<NodeJS.ReadableStream> {
const out = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: objectKey }));
if (!out.Body) {
throw new StorageError(ErrorCodes.UPLOAD_VERIFY_FAILED, `getStream: 对象无 Body: ${objectKey}`);
}
return out.Body as Readable;
}
async put(objectKey: string, body: Buffer, contentType: string): Promise<{ etag: string }> {
const out = await this.client.send(
new PutObjectCommand({ Bucket: this.bucket, Key: objectKey, ContentType: contentType, Body: body }),
);
return { etag: out.ETag ?? '' };
}
/**
* 禁止覆盖语义的复制(staging → 正式键):
* - dest 已存在且字节数与 expectedBytes 一致 → 幂等返回 created:false(同 etag);
* - dest 已存在但字节数不符 → StorageError(UPLOAD_VERIFY_FAILED);
* - dest 不存在 → CopyObject(MetadataDirective=COPY,MinIO 需显式指定以保留源元数据)
* 后 head 复核字节数再返回 created:true。
*
* 已知竞态窗口:MinIO 的 CopyObject 不支持条件写(无 forbid-overwrite 头),两个并发
* copyIfAbsent 同时通过 head 检查后,后发的 CopyObject 会覆盖先发的结果。本项目接受该
* 窗口:同一会话的并发调用复制的是同一个 staging 源对象,覆盖结果字节一致(幂等等价);
* 跨会话撞键理论上不可能(正式键含会话/照片唯一标识)。生产 OSS 适配器将使用
* x-oss-forbid-overwrite 头消除该窗口(G1 验收点)。
*/
async copyIfAbsent(src: string, dest: string, expectedBytes: number): Promise<{ created: boolean; etag: string }> {
const existing = await this.head(dest);
if (existing) {
if (existing.bytes === expectedBytes) return { created: false, etag: existing.etag };
throw new StorageError(
ErrorCodes.UPLOAD_VERIFY_FAILED,
`copyIfAbsent: dest 已存在且字节数不符(expected=${expectedBytes}, actual=${existing.bytes}): ${dest}`,
);
}
// CopySource 须为 "bucket/key" 且 key 部分 URL 编码(保留 / 分隔符)
const copySource = [this.bucket, ...src.split('/').map(encodeURIComponent)].join('/');
await this.client.send(
new CopyObjectCommand({
Bucket: this.bucket,
Key: dest,
CopySource: copySource,
MetadataDirective: 'COPY',
}),
);
const verified = await this.head(dest);
if (!verified || verified.bytes !== expectedBytes) {
throw new StorageError(
ErrorCodes.UPLOAD_VERIFY_FAILED,
`copyIfAbsent: 复制后复核失败(对象缺失或字节数不符): ${dest}`,
);
}
return { created: true, etag: verified.etag };
}
}
+19
View File
@@ -0,0 +1,19 @@
// packages/storage/src/port.ts —— 存储端口(唯一抽象):生产 OSS 适配器(P5/G1)
// 与本包 MinIO 适配器实现同一接口;api(presign/verify)与 worker(variants put/get)
// 只依赖此端口,不感知后端差异。
export interface UploadCredential {
mode: 'presigned';
url: string;
method: 'PUT';
headers: Record<string, string>;
expiresAt: string; // ISO
}
export interface StoragePort {
presignPut(objectKey: string, bytes: number, contentType: string, ttlSeconds: number): Promise<UploadCredential>;
presignGet(objectKey: string, ttlSeconds: number): Promise<string>;
head(objectKey: string): Promise<{ bytes: number; etag: string } | null>;
getStream(objectKey: string): Promise<NodeJS.ReadableStream>;
put(objectKey: string, body: Buffer, contentType: string): Promise<{ etag: string }>;
/** 复制到正式键;目标已存在时校验一致性并幂等返回(禁止覆盖语义)。 */
copyIfAbsent(src: string, dest: string, expectedBytes: number): Promise<{ created: boolean; etag: string }>;
}
+16
View File
@@ -0,0 +1,16 @@
// packages/storage/test/load-env.ts —— 与 services/api/test/load-env.ts 同约定:
// Jest 侧唯一 env 来源是仓库根 .env。本包嵌套层级与 api 不同(packages/storage/test
// vs services/api/test),故向上查找 pnpm-workspace.yaml 定位仓库根,不硬编码层数。
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { config } from 'dotenv';
let dir = __dirname;
while (!existsSync(join(dir, 'pnpm-workspace.yaml'))) {
const parent = dirname(dir);
if (parent === dir) {
throw new Error('load-env: 未找到 pnpm-workspace.yaml,无法定位仓库根(.env)');
}
dir = parent;
}
config({ path: join(dir, '.env') });
@@ -0,0 +1,93 @@
// packages/storage/test/minio.adapter.spec.ts —— MinIO 真实集成测试(依赖
// docker compose 的 photo-live-minio-1 与仓库根 .env 的 S3_* 配置)。
// 每次运行使用唯一前缀 test/<ts>-<uuid>/,避免历史残留对象干扰断言。
import { randomBytes, randomUUID } from 'node:crypto';
import { ErrorCodes } from '@photo/contracts';
import { StorageError, createStorageFromEnv, type MinioStorageAdapter } from '../src/index';
const PREFIX = `test/${Date.now()}-${randomUUID()}`;
describe('MinioStorageAdapter(真实 MinIO 集成)', () => {
let storage: MinioStorageAdapter;
beforeAll(() => {
storage = createStorageFromEnv();
});
it('presignPut: 客户端按凭证直接 PUT 后,head 返回精确字节数与 etag', async () => {
const key = `${PREFIX}/presign-put.bin`;
const body = randomBytes(2048);
const credential = await storage.presignPut(key, body.length, 'application/octet-stream', 300);
expect(credential.mode).toBe('presigned');
expect(credential.method).toBe('PUT');
expect(credential.headers['Content-Type']).toBe('application/octet-stream');
expect(new Date(credential.expiresAt).getTime()).toBeGreaterThan(Date.now());
const res = await fetch(credential.url, { method: credential.method, headers: credential.headers, body });
expect(res.ok).toBe(true);
const head = await storage.head(key);
expect(head).not.toBeNull();
expect(head?.bytes).toBe(body.length);
expect(head?.etag).toBeTruthy();
});
it('put + getStream: 写入后流式读回的字节与写入一致', async () => {
const key = `${PREFIX}/put-get.bin`;
const body = randomBytes(4096);
const { etag } = await storage.put(key, body, 'application/octet-stream');
expect(etag).toBeTruthy();
const stream = await storage.getStream(key);
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk as Uint8Array));
}
expect(Buffer.concat(chunks).equals(body)).toBe(true);
});
it('copyIfAbsent: 首次 created:true;同参数重复调用幂等 created:false 且 etag 不变', async () => {
const srcKey = `${PREFIX}/copy-src.bin`;
const destKey = `${PREFIX}/copy-dest.bin`;
const body = randomBytes(512);
await storage.put(srcKey, body, 'application/octet-stream');
const first = await storage.copyIfAbsent(srcKey, destKey, body.length);
expect(first.created).toBe(true);
expect(first.etag).toBeTruthy();
const second = await storage.copyIfAbsent(srcKey, destKey, body.length);
expect(second.created).toBe(false);
expect(second.etag).toBe(first.etag);
});
it('copyIfAbsent: dest 已存在但字节数不符 → StorageError(UPLOAD_VERIFY_FAILED)', async () => {
const srcKey = `${PREFIX}/mismatch-src.bin`;
const destKey = `${PREFIX}/mismatch-dest.bin`;
const srcBody = randomBytes(64);
await storage.put(srcKey, srcBody, 'application/octet-stream');
// 预置一个不同大小的 dest 对象,制造核验失败
await storage.put(destKey, randomBytes(128), 'application/octet-stream');
const promise = storage.copyIfAbsent(srcKey, destKey, srcBody.length);
await expect(promise).rejects.toBeInstanceOf(StorageError);
await expect(promise).rejects.toMatchObject({ code: ErrorCodes.UPLOAD_VERIFY_FAILED });
});
it('head: 缺失键返回 null(不抛错)', async () => {
const head = await storage.head(`${PREFIX}/missing.bin`);
expect(head).toBeNull();
});
it('presignGet: 预签名 URL 取回的字节与对象一致', async () => {
const key = `${PREFIX}/presign-get.bin`;
const body = randomBytes(1024);
await storage.put(key, body, 'application/octet-stream');
const url = await storage.presignGet(key, 300);
const res = await fetch(url);
expect(res.ok).toBe(true);
expect(Buffer.from(await res.arrayBuffer()).equals(body)).toBe(true);
});
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"lib": ["ES2022"],
"module": "commonjs",
"moduleResolution": "node10",
"declaration": true,
"outDir": "dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": ["src"]
}
+346 -3
View File
@@ -46,7 +46,7 @@ importers:
version: 7.13.0(typescript@5.9.3)
ts-jest:
specifier: ^29.2.0
version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0)(typescript@5.9.3)
version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)))(typescript@5.9.3)
typescript:
specifier: ^5.6.0
version: 5.9.3
@@ -64,6 +64,37 @@ importers:
specifier: ^5.6.0
version: 5.9.3
packages/storage:
dependencies:
'@aws-sdk/client-s3':
specifier: ^3.700.0
version: 3.1134.0
'@aws-sdk/s3-request-presigner':
specifier: ^3.700.0
version: 3.1134.0
'@photo/contracts':
specifier: workspace:*
version: link:../contracts
devDependencies:
'@types/jest':
specifier: ^29.5.13
version: 29.5.14
'@types/node':
specifier: ^22.10.0
version: 22.20.3
dotenv:
specifier: ^16.4.5
version: 16.6.1
jest:
specifier: ^29.7.0
version: 29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3))
ts-jest:
specifier: ^29.2.5
version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)))(typescript@5.9.3)
typescript:
specifier: ^5.6.0
version: 5.9.3
services/api:
dependencies:
'@nestjs/common':
@@ -120,7 +151,7 @@ importers:
version: 7.2.2
ts-jest:
specifier: ^29.2.5
version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0)(typescript@5.9.3)
version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)))(typescript@5.9.3)
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@22.20.3)(typescript@5.9.3)
@@ -130,6 +161,82 @@ importers:
packages:
'@aws-sdk/checksums@3.1001.0':
resolution: {integrity: sha512-6uTniZc87q+B5eXouGTl+7Tmc482rEeCcvxpsvREP8EfF0gvloRZ41UOA9sbSJlyy8TbqIBXb3kKfKarEArUQA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-s3@3.1134.0':
resolution: {integrity: sha512-dHgbuWMkladVNpR5gEQ7K9ub20w/hcf9IJXtMr+S3/rYpr6Z4gWUEX1seAPj00nAxJqtAaYvX3wdr2BVukZXXQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/core@3.978.0':
resolution: {integrity: sha512-2yX9LUmxPklVjSGTb8dfnWRJSiFQ3TeH2nn7G1mdKHTfnabzF0+gfrS8rYfLWmZrQ8A3mEcxMJjRc51dL5KWaA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-env@3.972.71':
resolution: {integrity: sha512-JN+JHruYZw3GUZB8YGAlDk4wTDPOEAEEdEzj5nS0xodWR4smzHsN7PnK2j6IeOsDIj2aqua5DSbhXl9Gtf90FQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-http@3.972.73':
resolution: {integrity: sha512-uyYYnJOnlis8uQzaYGPd7N1JoioCoNpXgnkXYixsWJXHXgXyYi8WXJSDfofxJeWfQIGWLe2Nwyq60Uc7MZdVOg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-ini@3.973.16':
resolution: {integrity: sha512-i++ly+0Uxa+u3ebSSyr0S/3CFhFJDxCXT3+Zj+mW2bXenEx5bKGCdTIKFu39SgXBNhWDjex/8cXUx9MUTMCrTw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-login@3.972.78':
resolution: {integrity: sha512-eUtswnXu0+Ii9ieRK+0L7aPFV3Z/dnW2VntJzjBP9xs8s+8p5nBNuymIXtXwZ+5r5+XJP3e32nMkuZ/r0HozEA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-node@3.972.83':
resolution: {integrity: sha512-jdso7ejzfRnatxMUZK4S/U6KbaDPCvfIV4XL+IQAPFDBt5rj5Fq595euqlK8Le4lNCMFR9oUpt+1l0aMgaayOQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-process@3.972.71':
resolution: {integrity: sha512-lYmXJa4gvq4xN1lrT5NiP5vIYYKcGWAdj8y+8o6dlcateB5eF3Dn8DtmjjHKfMBrTPAMr2pebIiX/UOj8c1/UA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-sso@3.973.15':
resolution: {integrity: sha512-6Jhcf4v0pSFdjk1EW2kvzuEBKD+UZ2uNcHUIglKKLndD20YhvkL2kdmDOV5/j4mYuWWwe/a1FQ1aomU86/Cg5Q==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-web-identity@3.972.77':
resolution: {integrity: sha512-uylIQSUWpfLuH2LovxEEfwzJGM/SabLOfLMg6YXu/E8jJEKUdpdILCVCQCdFvHyu/7dLJOHPMfrSwduxO56NkQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-sdk-s3@3.972.76':
resolution: {integrity: sha512-NfnTkVUTBKTBuBgqaapFK9r3YdkKt1b2oRvgLzZq91bwNKh6ZS0S7sEcheguttREaL4iyfs/xQnqD7Z7AsWSsA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/nested-clients@3.997.45':
resolution: {integrity: sha512-mooq9Q+jLa18VoM7HouczmslZU60iiB0aKc/Ztnq/luIL1ud0z4DnYprLR/ZO1gp331S9tJctM1HZr7u6YKBXQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/s3-request-presigner@3.1134.0':
resolution: {integrity: sha512-75DC3biOXXmSWqCN2hwJmhMPjDdgc1twjNJEknuWhY9M/E9eFIQufXkwOQx5raNTg6P/FbCgk9tp4y2SZ7vZFA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/signature-v4-multi-region@3.996.46':
resolution: {integrity: sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.1129.0':
resolution: {integrity: sha512-Sbl3rpzQdsG4ZK2zh0JWUYyZPKKorJlVOddA2T0DVbKJFrsW8J6wgnslxxUH04+WaBMr4A1HzJZvZX0xUvkniA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/types@3.974.5':
resolution: {integrity: sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/xml-builder@3.972.40':
resolution: {integrity: sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==}
engines: {node: '>=20.0.0'}
'@aws/lambda-invoke-store@0.3.0':
resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==}
engines: {node: '>=18.0.0'}
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
@@ -691,6 +798,30 @@ packages:
'@sinonjs/fake-timers@10.3.0':
resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==}
'@smithy/core@3.34.1':
resolution: {integrity: sha512-dLcOUxz8YCv1RZUMKq6GbyUf95pLbrqh34bPvpCZ1+CByFF31BEAFewZjsGCnVsZTKdThNENfGyAgk2TJqVwSw==}
engines: {node: '>=18.0.0'}
'@smithy/credential-provider-imds@4.5.2':
resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==}
engines: {node: '>=18.0.0'}
'@smithy/fetch-http-handler@5.8.0':
resolution: {integrity: sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==}
engines: {node: '>=18.0.0'}
'@smithy/node-http-handler@4.12.1':
resolution: {integrity: sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==}
engines: {node: '>=18.0.0'}
'@smithy/signature-v4@5.7.3':
resolution: {integrity: sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==}
engines: {node: '>=18.0.0'}
'@smithy/types@4.18.0':
resolution: {integrity: sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==}
engines: {node: '>=18.0.0'}
'@tokenizer/inflate@0.2.7':
resolution: {integrity: sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==}
engines: {node: '>=18'}
@@ -991,6 +1122,9 @@ packages:
resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
bowser@2.14.1:
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
brace-expansion@1.1.21:
resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
@@ -2928,6 +3062,180 @@ packages:
snapshots:
'@aws-sdk/checksums@3.1001.0':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.34.1
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/client-s3@3.1134.0':
dependencies:
'@aws-sdk/checksums': 3.1001.0
'@aws-sdk/core': 3.978.0
'@aws-sdk/credential-provider-node': 3.972.83
'@aws-sdk/middleware-sdk-s3': 3.972.76
'@aws-sdk/signature-v4-multi-region': 3.996.46
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.34.1
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/core@3.978.0':
dependencies:
'@aws-sdk/types': 3.974.5
'@aws-sdk/xml-builder': 3.972.40
'@aws/lambda-invoke-store': 0.3.0
'@smithy/core': 3.34.1
'@smithy/signature-v4': 5.7.3
'@smithy/types': 4.18.0
bowser: 2.14.1
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.71':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.34.1
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/credential-provider-http@3.972.73':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.34.1
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.973.16':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/credential-provider-env': 3.972.71
'@aws-sdk/credential-provider-http': 3.972.73
'@aws-sdk/credential-provider-login': 3.972.78
'@aws-sdk/credential-provider-process': 3.972.71
'@aws-sdk/credential-provider-sso': 3.973.15
'@aws-sdk/credential-provider-web-identity': 3.972.77
'@aws-sdk/nested-clients': 3.997.45
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.34.1
'@smithy/credential-provider-imds': 4.5.2
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/credential-provider-login@3.972.78':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/nested-clients': 3.997.45
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.34.1
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/credential-provider-node@3.972.83':
dependencies:
'@aws-sdk/credential-provider-env': 3.972.71
'@aws-sdk/credential-provider-http': 3.972.73
'@aws-sdk/credential-provider-ini': 3.973.16
'@aws-sdk/credential-provider-process': 3.972.71
'@aws-sdk/credential-provider-sso': 3.973.15
'@aws-sdk/credential-provider-web-identity': 3.972.77
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.34.1
'@smithy/credential-provider-imds': 4.5.2
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/credential-provider-process@3.972.71':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.34.1
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.973.15':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/nested-clients': 3.997.45
'@aws-sdk/token-providers': 3.1129.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.34.1
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/credential-provider-web-identity@3.972.77':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/nested-clients': 3.997.45
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.34.1
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/middleware-sdk-s3@3.972.76':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/signature-v4-multi-region': 3.996.46
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.34.1
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/nested-clients@3.997.45':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/signature-v4-multi-region': 3.996.46
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.34.1
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/s3-request-presigner@3.1134.0':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/signature-v4-multi-region': 3.996.46
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.34.1
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/signature-v4-multi-region@3.996.46':
dependencies:
'@aws-sdk/types': 3.974.5
'@smithy/signature-v4': 5.7.3
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/token-providers@3.1129.0':
dependencies:
'@aws-sdk/core': 3.978.0
'@aws-sdk/nested-clients': 3.997.45
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.34.1
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/types@3.974.5':
dependencies:
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/xml-builder@3.972.40':
dependencies:
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws/lambda-invoke-store@0.3.0': {}
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@@ -3675,6 +3983,39 @@ snapshots:
dependencies:
'@sinonjs/commons': 3.0.1
'@smithy/core@3.34.1':
dependencies:
'@smithy/types': 4.18.0
tslib: 2.8.1
'@smithy/credential-provider-imds@4.5.2':
dependencies:
'@smithy/core': 3.34.1
'@smithy/types': 4.18.0
tslib: 2.8.1
'@smithy/fetch-http-handler@5.8.0':
dependencies:
'@smithy/core': 3.34.1
'@smithy/types': 4.18.0
tslib: 2.8.1
'@smithy/node-http-handler@4.12.1':
dependencies:
'@smithy/core': 3.34.1
'@smithy/types': 4.18.0
tslib: 2.8.1
'@smithy/signature-v4@5.7.3':
dependencies:
'@smithy/core': 3.34.1
'@smithy/types': 4.18.0
tslib: 2.8.1
'@smithy/types@4.18.0':
dependencies:
tslib: 2.8.1
'@tokenizer/inflate@0.2.7':
dependencies:
debug: 4.4.3(supports-color@10.2.2)
@@ -4033,6 +4374,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
bowser@2.14.1: {}
brace-expansion@1.1.21:
dependencies:
balanced-match: 1.0.2
@@ -5914,7 +6257,7 @@ snapshots:
dependencies:
typescript: 5.9.3
ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0)(typescript@5.9.3):
ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)))(typescript@5.9.3):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0