From 4ceaef38e9ff16734fdc964cf8784e5648435049 Mon Sep 17 00:00:00 2001 From: Kauan Guesser Date: Thu, 27 Aug 2026 18:31:05 -0300 Subject: [PATCH] feat: add native conditionals and stable NestJS 12 support --- .changeset/config.json | 2 +- .changeset/delegate-native-conditionals.md | 16 + .changeset/pre.json | 20 +- .changeset/{ => pre}/add-agent-workspaces.md | 0 .changeset/{ => pre}/add-artifact-storage.md | 0 .../add-workspace-last-write-wins.md | 0 .../compose-workspace-tool-conflicts.md | 0 .../fail-closed-conditional-files-policy.md | 0 .../fix-workspace-cursor-continuity.md | 0 .../{ => pre}/framework-neutral-core.md | 0 .../{ => pre}/harden-storage-boundaries.md | 0 .../{ => pre}/initial-storage-release.md | 0 .../{ => pre}/map-foreign-files-not-found.md | 0 .../restore-generic-storage-boundary.md | 0 .../{ => pre}/runtime-provider-selection.md | 0 .../split-s3-conditional-capabilities.md | 0 .changeset/stable-nest-twelve.md | 5 + .github/workflows/ci.yml | 16 +- .github/workflows/release.yml | 13 +- README.md | 118 +- SECURITY.md | 37 +- docs/provider-conformance.md | 6 + package.json | 44 +- pnpm-lock.yaml | 1876 +++++++---------- pnpm-workspace.yaml | 21 +- src/ai-sdk/ai-sdk-workspace-tools.spec.ts | 84 + src/ai-sdk/ai-sdk-workspace-tools.ts | 46 +- src/ai-sdk/index.ts | 1 + src/files-sdk/files-sdk.driver.spec.ts | 555 ++++- src/files-sdk/files-sdk.driver.ts | 725 ++++++- src/files-sdk/fs/fs.driver.spec.ts | 404 +++- src/files-sdk/fs/index.ts | 1092 +++++++--- src/files-sdk/provider/index.ts | 45 +- .../provider/provider.driver.spec.ts | 31 +- src/files-sdk/s3/construction-metadata.ts | 20 + src/files-sdk/s3/index.ts | 777 ++++++- src/files-sdk/s3/s3.driver.spec.ts | 565 ++++- src/gateway/storage-gateway.controller.ts | 8 +- src/storage.client.spec.ts | 150 ++ src/storage.client.ts | 74 +- src/storage.error.spec.ts | 53 + src/storage.error.ts | 21 + src/storage.types.ts | 4 + src/testing/provider-conformance.spec.ts | 40 + src/testing/provider-conformance.ts | 54 +- src/workspace/storage-workspace.error.ts | 18 +- src/workspace/storage-workspace.spec.ts | 90 +- src/workspace/storage-workspace.ts | 2 + test/gateway.e2e-spec.ts | 32 + 49 files changed, 5161 insertions(+), 1904 deletions(-) create mode 100644 .changeset/delegate-native-conditionals.md rename .changeset/{ => pre}/add-agent-workspaces.md (100%) rename .changeset/{ => pre}/add-artifact-storage.md (100%) rename .changeset/{ => pre}/add-workspace-last-write-wins.md (100%) rename .changeset/{ => pre}/compose-workspace-tool-conflicts.md (100%) rename .changeset/{ => pre}/fail-closed-conditional-files-policy.md (100%) rename .changeset/{ => pre}/fix-workspace-cursor-continuity.md (100%) rename .changeset/{ => pre}/framework-neutral-core.md (100%) rename .changeset/{ => pre}/harden-storage-boundaries.md (100%) rename .changeset/{ => pre}/initial-storage-release.md (100%) rename .changeset/{ => pre}/map-foreign-files-not-found.md (100%) rename .changeset/{ => pre}/restore-generic-storage-boundary.md (100%) rename .changeset/{ => pre}/runtime-provider-selection.md (100%) rename .changeset/{ => pre}/split-s3-conditional-capabilities.md (100%) create mode 100644 .changeset/stable-nest-twelve.md diff --git a/.changeset/config.json b/.changeset/config.json index 2be13d4..3bbe3b2 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,5 +1,5 @@ { - "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", + "$schema": "https://unpkg.com/@changesets/config@4.0.0/schema.json", "changelog": "@changesets/cli/changelog", "commit": false, "fixed": [], diff --git a/.changeset/delegate-native-conditionals.md b/.changeset/delegate-native-conditionals.md new file mode 100644 index 0000000..a4bb4ca --- /dev/null +++ b/.changeset/delegate-native-conditionals.md @@ -0,0 +1,16 @@ +--- +'@nestm/storage': minor +--- + +Delegate conditional create, replace, exact ETag read, delete, and paired copy +to the Files SDK 2.3 operation pipeline so plugins, hooks, retries, and receipts +apply without bypassing native provider preconditions. Keep the direct NestM +fallback only for version predicates, conditional multipart/resumable +completion, and one-sided conditional copies, and fail those shapes closed when +caller Files policy is configured. + +Expose paired-copy dependency flags in provider capabilities and preserve +`StorageError.applied` plus `appliedEtag` so callers can reconcile a conditional +mutation that committed before a post-operation failure. Preserve the same +bounded reconciliation signal through workspace, AI-tool, and gateway error +boundaries. diff --git a/.changeset/pre.json b/.changeset/pre.json index 2fb2e7a..9a89b8a 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -1,22 +1,4 @@ { "mode": "pre", - "tag": "alpha", - "initialVersions": { - "@nestm/storage": "0.1.0-alpha.0" - }, - "changesets": [ - "add-agent-workspaces", - "add-artifact-storage", - "add-workspace-last-write-wins", - "compose-workspace-tool-conflicts", - "fail-closed-conditional-files-policy", - "fix-workspace-cursor-continuity", - "framework-neutral-core", - "harden-storage-boundaries", - "initial-storage-release", - "map-foreign-files-not-found", - "restore-generic-storage-boundary", - "runtime-provider-selection", - "split-s3-conditional-capabilities" - ] + "tag": "alpha" } diff --git a/.changeset/add-agent-workspaces.md b/.changeset/pre/add-agent-workspaces.md similarity index 100% rename from .changeset/add-agent-workspaces.md rename to .changeset/pre/add-agent-workspaces.md diff --git a/.changeset/add-artifact-storage.md b/.changeset/pre/add-artifact-storage.md similarity index 100% rename from .changeset/add-artifact-storage.md rename to .changeset/pre/add-artifact-storage.md diff --git a/.changeset/add-workspace-last-write-wins.md b/.changeset/pre/add-workspace-last-write-wins.md similarity index 100% rename from .changeset/add-workspace-last-write-wins.md rename to .changeset/pre/add-workspace-last-write-wins.md diff --git a/.changeset/compose-workspace-tool-conflicts.md b/.changeset/pre/compose-workspace-tool-conflicts.md similarity index 100% rename from .changeset/compose-workspace-tool-conflicts.md rename to .changeset/pre/compose-workspace-tool-conflicts.md diff --git a/.changeset/fail-closed-conditional-files-policy.md b/.changeset/pre/fail-closed-conditional-files-policy.md similarity index 100% rename from .changeset/fail-closed-conditional-files-policy.md rename to .changeset/pre/fail-closed-conditional-files-policy.md diff --git a/.changeset/fix-workspace-cursor-continuity.md b/.changeset/pre/fix-workspace-cursor-continuity.md similarity index 100% rename from .changeset/fix-workspace-cursor-continuity.md rename to .changeset/pre/fix-workspace-cursor-continuity.md diff --git a/.changeset/framework-neutral-core.md b/.changeset/pre/framework-neutral-core.md similarity index 100% rename from .changeset/framework-neutral-core.md rename to .changeset/pre/framework-neutral-core.md diff --git a/.changeset/harden-storage-boundaries.md b/.changeset/pre/harden-storage-boundaries.md similarity index 100% rename from .changeset/harden-storage-boundaries.md rename to .changeset/pre/harden-storage-boundaries.md diff --git a/.changeset/initial-storage-release.md b/.changeset/pre/initial-storage-release.md similarity index 100% rename from .changeset/initial-storage-release.md rename to .changeset/pre/initial-storage-release.md diff --git a/.changeset/map-foreign-files-not-found.md b/.changeset/pre/map-foreign-files-not-found.md similarity index 100% rename from .changeset/map-foreign-files-not-found.md rename to .changeset/pre/map-foreign-files-not-found.md diff --git a/.changeset/restore-generic-storage-boundary.md b/.changeset/pre/restore-generic-storage-boundary.md similarity index 100% rename from .changeset/restore-generic-storage-boundary.md rename to .changeset/pre/restore-generic-storage-boundary.md diff --git a/.changeset/runtime-provider-selection.md b/.changeset/pre/runtime-provider-selection.md similarity index 100% rename from .changeset/runtime-provider-selection.md rename to .changeset/pre/runtime-provider-selection.md diff --git a/.changeset/split-s3-conditional-capabilities.md b/.changeset/pre/split-s3-conditional-capabilities.md similarity index 100% rename from .changeset/split-s3-conditional-capabilities.md rename to .changeset/pre/split-s3-conditional-capabilities.md diff --git a/.changeset/stable-nest-twelve.md b/.changeset/stable-nest-twelve.md new file mode 100644 index 0000000..444f6ab --- /dev/null +++ b/.changeset/stable-nest-twelve.md @@ -0,0 +1,5 @@ +--- +'@nestm/storage': patch +--- + +Replace the NestJS 12 prerelease peer range and test matrix with the stable NestJS 12 line, and refresh the package toolchain and optional integration dependencies. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbff6cd..867c7f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,7 @@ jobs: - run: pnpm run test env: { CI: 'true' } - # Non-blocking early warning for changes in Nest's prerelease API. + # Non-blocking early warning for changes in Nest's next release line. canary: runs-on: ubuntu-latest continue-on-error: true @@ -73,17 +73,3 @@ jobs: - run: pnpm install --no-frozen-lockfile - run: pnpm up "@nestjs/*@next" - run: pnpm run test - - npm-peer-smoke: - runs-on: ubuntu-latest - if: github.event_name != 'pull_request' - steps: - - uses: actions/setup-node@v7 - with: { node-version: 24 } - - run: | - mkdir smoke && cd smoke && npm init -y > /dev/null - if npm install @nestjs/common@next @nestjs/core@next @nestjs/platform-express@next reflect-metadata rxjs --no-audit --no-fund; then - echo "::warning::Nest 12 alpha sibling peers appear fixed — review pnpm-workspace.yaml and README." - else - echo "npm ERESOLVE still present (expected while Nest 12 alphas declare stale sibling peers)." - fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd3b316..59b8c95 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,13 +41,14 @@ jobs: - run: pnpm dlx @arethetypeswrong/cli --pack . --profile esm-only - name: Create release PR or publish to npm id: changesets - uses: changesets/action@v1 + uses: changesets/action@8488615a623b1b9c987934bb89eae8af6a946ac1 # v2.1.1 with: - publish: pnpm run release - version: pnpm changeset version - title: 'chore: release @nestm/storage' - commit: 'chore: release @nestm/storage' - createGithubReleases: true + github-token: ${{ secrets.GITHUB_TOKEN }} + publish-script: pnpm run release + version-script: pnpm changeset version + pr-title: 'chore: release @nestm/storage' + commit-message: 'chore: release @nestm/storage' + create-github-releases: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Reconcile git tag with npm diff --git a/README.md b/README.md index 53bddba..c0db28c 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ The package uses [`files-sdk`](https://github.com/haydenbleasel/files-sdk) as its provider engine, but owns the API injected into Nest applications. Provider SDK types, errors, and `files.raw` do not leak through the root package. -> This package targets the NestJS 12 prerelease line and is itself published on -> the `alpha` dist-tag. +> This package targets stable NestJS 12 and is itself published on the `alpha` +> dist-tag. ## Requirements @@ -17,10 +17,9 @@ SDK types, errors, and `files.raw` do not leak through the root package. - ESM The framework-neutral `@nestm/storage/core` entry point does not require -NestJS. The root entry point and HTTP gateway additionally require NestJS -`12.0.0-alpha.5` or newer in the Nest 12 prerelease line, `reflect-metadata`, -and RxJS. Those framework peers are optional at installation time so core-only -consumers do not download NestJS. +NestJS. The root entry point and HTTP gateway additionally require NestJS 12, +`reflect-metadata`, and RxJS. Those framework peers are optional at installation +time so core-only consumers do not download NestJS. ## Install @@ -33,7 +32,7 @@ pinned engine explicitly only when the application imports another adapter such as `files-sdk/gcs`: ```sh -pnpm add files-sdk@2.2.3 +pnpm add files-sdk@2.3.0 ``` Pass AWS-SDK-backed S3 adapters through the package-owned `s3()` and @@ -58,20 +57,15 @@ pnpm add @google-cloud/storage google-auth-library pnpm add @azure/storage-blob @azure/core-auth @azure/identity ``` -`@aws-sdk/client-s3` 3.919.0 or newer is required. Earlier releases omit -destination `If-Match` and `If-None-Match` from the serialized `CopyObject` -request even when those fields appear in a newer compile-time command shape; -the package peer range and packed minimum-peer smoke test enforce this floor. +`@aws-sdk/client-s3` 3.1079.0 or newer is required, matching the Files SDK 2.3 +peer floor. The supported range includes destination `If-Match` and +`If-None-Match` serialization for `CopyObject`; the package peer range and +packed minimum-peer smoke test enforce this floor. `files-sdk` currently declares its optional Nest peer for Nest 10 and 11. This library does not import `files-sdk/nestjs`; the Nest 12 integration is entirely owned here. A package manager may nevertheless report that temporary optional -peer mismatch while Nest 12 remains prerelease. - -NestJS 12 alpha also has prerelease peer declarations that npm may reject under -its strict resolver. If npm reports an `ERESOLVE` error for Nest's own peers, -install with `npm install --legacy-peer-deps`; pnpm works with the repository's -checked-in peer-version policy. +peer mismatch until Files SDK widens its declaration to include Nest 12. ## Framework-neutral core @@ -268,7 +262,7 @@ Install AI SDK 7 and Zod only in applications that use the optional adapter: pnpm add ai@^7 zod@^4 ``` -`files-sdk` 2.2.x still declares an optional `ai@^6` peer for its own adapter, +`files-sdk` 2.3.x still declares an optional `ai@^6` peer for its own adapter, so some package managers may print a peer warning when AI SDK 7 is installed. This package does not import that adapter; `@nestm/storage/ai-sdk` targets AI SDK 7 directly. @@ -609,36 +603,37 @@ provide: NestJS 12 named stores, exact native conditional/CAS capabilities, `StorageWorkspace` permissions and limits, bounded storage errors, and capability-scoped AI tools. -On the alpha.8 base, ordinary `FilesSdkStorageDriver` operations already -delegate to the Files SDK pipeline. The exception is the native conditional -adapter extensions: the current Files SDK operation union does not include -them, so they cannot run through caller-configured Files plugins, hooks, or -receipts. Until Files SDK provides one interception boundary for ordinary and -conditional operations, the driver applies this interim fail-closed -compatibility rule: - -| Caller Files configuration | Ordinary operations | Conditional operations | -| ------------------------------------------------- | ------------------------------------ | -------------------------------------------------------- | -| No plugins, active hooks, or receipts | Files pipeline | Advertised when the adapter supports the exact primitive | -| One or more plugins | Files pipeline, including transforms | Hidden; direct invocation returns `NOT_SUPPORTED` | -| Any active hook | Files pipeline and hook callbacks | Hidden; direct invocation returns `NOT_SUPPORTED` | -| Receipts enabled with `true` or an options object | Files pipeline and receipts | Hidden; direct invocation returns `NOT_SUPPORTED` | - -An empty plugin list, an empty hooks object, and `receipts: false` do not trigger -the gate. NestM's internal physical-key guard does not trigger it either. When -available, direct conditional paths independently apply prefixing, the -physical-key budget, mutation read-only restrictions, default -retry/signal/timeout options, and bounded error mapping. `StoragePlugin` remains -a separate veto/observation boundary; it is not a substitute for Files body or -result transforms. This compatibility gate is intended to be removed once -native CAS can traverse the upstream operation and plugin pipeline rather than -becoming a second generic CRUD facade here. - -`StorageWorkspace` therefore exposes both contracts without weakening either: -its existing create/replace, exact-read copy/move, and conditional-delete paths -retain native CAS and this fail-closed gate, while explicit overwrite and -unconditional-delete variants use the ordinary Files pipeline. Lower-level -conditional client and driver APIs remain available to callers that need them. +Files SDK 2.3 provides native conditional operations through the same +interception boundary as ordinary CRUD. NestM adapters expose their exact +create, replace, ETag read, delete, and paired conditional-copy primitives to +that boundary. These operations now pass through caller-configured Files +plugins, hooks, retries, and receipts; a body transform, veto, retry observer, +or audit policy therefore sees the conditional operation instead of being +bypassed. + +NestM retains a narrow direct fallback only for conditional shapes Files SDK +2.3 cannot represent: immutable version predicates, conditional +multipart/resumable completion, and a copy with only its source or only its +destination conditioned. Because those fallbacks cannot traverse the Files +operation pipeline, they remain fail-closed when caller Files policy is active: + +| Operation shape | Execution path | With Files plugins, active hooks, or receipts | +| ----------------------------------------------------------- | --------------------- | --------------------------------------------- | +| Ordinary operations | Files pipeline | Available | +| Create/replace/ETag read/delete/paired conditional copy | Files 2.3 pipeline | Available | +| Version, conditional multipart/resumable, or one-sided copy | NestM direct fallback | Hidden; invocation returns `NOT_SUPPORTED` | + +An empty plugin list, an empty hooks object, and `receipts: false` do not count +as caller policy. When available, a direct fallback still applies prefixing, +the physical-key budget, read-only restrictions, default retry/signal/timeout +options, and bounded error mapping. `StoragePlugin` remains a separate +veto/observation boundary; it is not a substitute for Files body or result +transforms. + +`StorageWorkspace` uses the Files pipeline whenever its conditional operation +has an upstream representation. Lower-level conditional client and driver APIs +remain available for applications that intentionally use the policy-free +NestM-only fallback shapes. ## Storage API @@ -702,6 +697,15 @@ completion. They also declare the complete physical-key byte budget. Callers must check the exact primitive they need; a missing field is unsupported and is never widened from another operation. +Some adapters expose conditional copy only as a paired source-and-destination +operation. In that case, +`capabilities.conditionalCopySource.requiresDestinationPredicate` and +`capabilities.conditionalCopyDestination.requiresSourcePredicate` are `true`. +`StorageClient.promote` rejects a request missing the required counterpart +before provider I/O. A paired request must also satisfy the advertised +create/replace bit and +`capabilities.conditionalCopyDestination.atomicWithSource`. + The physical-key budget applies to the exact key sent to the adapter. It therefore counts leading slashes for unprefixed drivers, the separator added to a configured driver prefix, and provider prefixes derived for `list` or @@ -1021,6 +1025,18 @@ when a provider adapter and this driver resolve separate copies of `files-sdk`. `isStorageError()` likewise recognizes branded and exact legacy structural errors produced by a duplicated `@nestm/storage` package copy. +For a conditional mutation, `error.applied === true` means the provider commit +succeeded but acknowledgement failed afterward, for example in an awaited +post-operation plugin. Conditional uploads also expose `error.appliedEtag` when +the committed generation is known. Do not blindly retry the original +predicate: reconcile the logical destination first, using an exact ETag read +when `appliedEtag` is present. This is a one-way signal: `applied === false` +does not prove that a remote mutation did not commit. A timeout, connection +loss, or exhausted retry can lose the provider's success response, so reconcile +ambiguous transport/provider failures before repeating a conditional mutation. +The sanitized workspace, AI-tool, and gateway error boundaries retain only +this bounded reconciliation metadata. + Capability flags cover range reads, native byte-level upload progress, delimiter listing, metadata, cache control, resumable uploads, server-side copy, conditional promotion, and signed transfers. @@ -1058,7 +1074,11 @@ StorageModule.forRoot({ Bodies are written verbatim at `/`. A `.meta.json` sidecar beside each one carries the content type, ETag, and custom metadata a filesystem has nowhere else to put; sidecars never surface as keys, and uploading a key ending -in `.meta.json` fails closed rather than colliding with one. +in `.meta.json` fails closed rather than colliding with one. Ordinary and +conditional mutations made through the decorated adapter share one +process-local lock domain. Conditional guarantees therefore require a +dedicated root: do not mutate it through another process, an unwrapped adapter, +or direct filesystem calls. ## License diff --git a/SECURITY.md b/SECURITY.md index f1c055c..af30d05 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -93,11 +93,17 @@ additional workflow controls, not the authorization boundary. Conditional mutations can still have an ambiguous outcome when a remote provider commits and then loses or violates its response, or when a configured -post-operation plugin fails after the driver has committed. The API fails -closed in that case: inspect the logical destination and reconcile it before -retrying. Create-only and ETag preconditions prevent a blind retry from -silently overwriting a different object, but they cannot make a multi-object -move transactionally atomic. +post-operation plugin fails after the driver has committed. Known post-commit +failures report `StorageError.applied === true`; a conditional upload also +carries `appliedEtag` when its committed generation is known. This is positive +evidence, not a complete remote-commit oracle: a timeout, connection loss, or +exhausted retry can lose a provider success response and still leave +`applied === false`. Do not reissue an ambiguous predicate blindly. Reconcile +the logical destination first, using an exact ETag read when `appliedEtag` is +available and inspecting the relevant source/destination state for copy or +delete. Create-only and ETag preconditions prevent a blind retry from silently +overwriting a different object, but they cannot make a multi-object move +transactionally atomic. This guarantee covers calls made through `StorageWorkspace`. It does not confine arbitrary `node:fs`, shell, subprocess, or native-code access in the @@ -106,12 +112,15 @@ inside an OS sandbox (container, VM, or equivalent) that exposes only a materialized workspace. Setting `cwd` to a workspace directory is not isolation. -For local filesystem storage, use a dedicated service-owned root and do not let -another untrusted process mutate its directory tree concurrently. High-level -Node filesystem checks reject symlinks and hard-linked object files in existing -workspace read and mutation paths, including metadata sidecars, but cannot -provide a race-proof boundary against an actor that can replace path components -between validation and use. For that threat model, mount only the workspace -into a separate UID/container/VM and synchronize approved results back through -storage. The local adapter also commits the body and metadata sidecar as two -files; a process crash between their atomic renames can require reconciliation. +For local filesystem storage, use a dedicated service-owned root and route all +in-process mutations through the package-decorated adapter; its ordinary and +conditional mutation paths share one lock domain. A separate process, +unwrapped adapter, or direct filesystem writer can bypass that coordination and +must not mutate the tree concurrently. High-level Node filesystem checks reject +symlinks and hard-linked object files in existing workspace read and mutation +paths, including metadata sidecars, but cannot provide a race-proof boundary +against an actor that can replace path components between validation and use. +For that threat model, mount only the workspace into a separate +UID/container/VM and synchronize approved results back through storage. The +local adapter also commits the body and metadata sidecar as two files; a process +crash between their atomic renames can require reconciliation. diff --git a/docs/provider-conformance.md b/docs/provider-conformance.md index 0c12dee..c140df8 100644 --- a/docs/provider-conformance.md +++ b/docs/provider-conformance.md @@ -110,6 +110,12 @@ Profiles that advertise source and destination predicates but leave `atomicWithSource` false run the same complete cross-product as negative cases: every combined pair must fail before it can alter the destination. +When a provider exposes only paired conditional copy, set +`conditionalCopySource.requiresDestinationPredicate` and +`conditionalCopyDestination.requiresSourcePredicate`. The harness then proves +that either predicate fails closed when exercised alone, while still running +the advertised paired combinations through the atomicity matrix. + Run only these suites with: ```sh diff --git a/package.json b/package.json index d2ae45e..bec8ef3 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "engines": { "node": ">=22.12.0" }, - "packageManager": "pnpm@11.20.0", + "packageManager": "pnpm@11.24.0", "publishConfig": { "access": "public" }, @@ -107,15 +107,15 @@ "release": "node scripts/publish.mjs" }, "dependencies": { - "files-sdk": "2.2.3" + "files-sdk": "2.3.0" }, "peerDependencies": { - "@aws-sdk/client-s3": "^3.919.0", + "@aws-sdk/client-s3": "^3.1079.0", "@aws-sdk/lib-storage": "^3.700.0", "@aws-sdk/s3-presigned-post": "^3.700.0", "@aws-sdk/s3-request-presigner": "^3.700.0", - "@nestjs/common": "^12.0.0-alpha.5", - "@nestjs/core": "^12.0.0-alpha.5", + "@nestjs/common": "^12.0.0", + "@nestjs/core": "^12.0.0", "ai": ">=7.0.0 <8.0.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", @@ -154,29 +154,29 @@ } }, "devDependencies": { - "@aws-sdk/client-s3": "3.1103.0", - "@aws-sdk/lib-storage": "3.1103.0", - "@aws-sdk/s3-presigned-post": "3.1103.0", - "@aws-sdk/s3-request-presigner": "3.1103.0", - "@changesets/cli": "2.31.1", - "@nestjs/common": "12.0.0-alpha.5", - "@nestjs/core": "12.0.0-alpha.5", - "@nestjs/platform-express": "12.0.0-alpha.5", - "@nestjs/platform-fastify": "12.0.0-alpha.5", - "@nestjs/testing": "12.0.0-alpha.5", - "@types/node": "26.1.2", + "@aws-sdk/client-s3": "3.1120.0", + "@aws-sdk/lib-storage": "3.1120.0", + "@aws-sdk/s3-presigned-post": "3.1120.0", + "@aws-sdk/s3-request-presigner": "3.1120.0", + "@changesets/cli": "3.0.1", + "@nestjs/common": "12.0.1", + "@nestjs/core": "12.0.1", + "@nestjs/platform-express": "12.0.1", + "@nestjs/platform-fastify": "12.0.1", + "@nestjs/testing": "12.0.1", + "@types/node": "26.4.0", "@types/supertest": "7.2.1", - "@vitest/coverage-v8": "4.1.10", - "ai": "7.0.64", - "fastify": "5.11.2", - "oxlint": "1.77.0", + "@vitest/coverage-v8": "4.1.11", + "ai": "7.0.83", + "fastify": "5.12.1", + "oxlint": "1.80.0", "prettier": "3.9.6", - "publint": "0.3.23", + "publint": "0.3.24", "reflect-metadata": "0.2.2", "rxjs": "7.8.2", "supertest": "7.2.2", "typescript": "7.0.2", - "vitest": "4.1.10", + "vitest": "4.1.11", "zod": "4.4.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 623e03f..0812f4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,71 +4,68 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -overrides: - multer: 2.2.0 - importers: .: dependencies: files-sdk: - specifier: 2.2.3 - version: 2.2.3(@aws-sdk/client-s3@3.1103.0)(@aws-sdk/lib-storage@3.1103.0(@aws-sdk/client-s3@3.1103.0))(@aws-sdk/s3-presigned-post@3.1103.0)(@aws-sdk/s3-request-presigner@3.1103.0)(@nestjs/common@12.0.0-alpha.5(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(ai@7.0.64(zod@4.4.3))(fastify@5.11.2)(hono@4.13.0)(supports-color@7.2.0)(zod@4.4.3) + specifier: 2.3.0 + version: 2.3.0(@aws-sdk/client-s3@3.1120.0)(@aws-sdk/lib-storage@3.1120.0(@aws-sdk/client-s3@3.1120.0))(@aws-sdk/s3-presigned-post@3.1120.0)(@aws-sdk/s3-request-presigner@3.1120.0)(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(ai@7.0.83(zod@4.4.3))(fastify@5.12.1)(hono@4.13.0)(supports-color@7.2.0)(zod@4.4.3) devDependencies: '@aws-sdk/client-s3': - specifier: 3.1103.0 - version: 3.1103.0 + specifier: 3.1120.0 + version: 3.1120.0 '@aws-sdk/lib-storage': - specifier: 3.1103.0 - version: 3.1103.0(@aws-sdk/client-s3@3.1103.0) + specifier: 3.1120.0 + version: 3.1120.0(@aws-sdk/client-s3@3.1120.0) '@aws-sdk/s3-presigned-post': - specifier: 3.1103.0 - version: 3.1103.0 + specifier: 3.1120.0 + version: 3.1120.0 '@aws-sdk/s3-request-presigner': - specifier: 3.1103.0 - version: 3.1103.0 + specifier: 3.1120.0 + version: 3.1120.0 '@changesets/cli': - specifier: 2.31.1 - version: 2.31.1(@types/node@26.1.2) + specifier: 3.0.1 + version: 3.0.1 '@nestjs/common': - specifier: 12.0.0-alpha.5 - version: 12.0.0-alpha.5(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) + specifier: 12.0.1 + version: 12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) '@nestjs/core': - specifier: 12.0.0-alpha.5 - version: 12.0.0-alpha.5(@nestjs/common@12.0.0-alpha.5(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@12.0.0-alpha.5)(reflect-metadata@0.2.2)(rxjs@7.8.2) + specifier: 12.0.1 + version: 12.0.1(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@12.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': - specifier: 12.0.0-alpha.5 - version: 12.0.0-alpha.5(@nestjs/common@12.0.0-alpha.5(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.0.0-alpha.5)(supports-color@7.2.0) + specifier: 12.0.1 + version: 12.0.1(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.0.1)(supports-color@7.2.0) '@nestjs/platform-fastify': - specifier: 12.0.0-alpha.5 - version: 12.0.0-alpha.5(@nestjs/common@12.0.0-alpha.5(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.0.0-alpha.5) + specifier: 12.0.1 + version: 12.0.1(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.0.1) '@nestjs/testing': - specifier: 12.0.0-alpha.5 - version: 12.0.0-alpha.5(@nestjs/common@12.0.0-alpha.5(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.0.0-alpha.5)(@nestjs/platform-express@12.0.0-alpha.5) + specifier: 12.0.1 + version: 12.0.1(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.0.1)(@nestjs/platform-express@12.0.1) '@types/node': - specifier: 26.1.2 - version: 26.1.2 + specifier: 26.4.0 + version: 26.4.0 '@types/supertest': specifier: 7.2.1 version: 7.2.1 '@vitest/coverage-v8': - specifier: 4.1.10 - version: 4.1.10(vitest@4.1.10) + specifier: 4.1.11 + version: 4.1.11(vitest@4.1.11) ai: - specifier: 7.0.64 - version: 7.0.64(zod@4.4.3) + specifier: 7.0.83 + version: 7.0.83(zod@4.4.3) fastify: - specifier: 5.11.2 - version: 5.11.2 + specifier: 5.12.1 + version: 5.12.1 oxlint: - specifier: 1.77.0 - version: 1.77.0 + specifier: 1.80.0 + version: 1.80.0 prettier: specifier: 3.9.6 version: 3.9.6 publint: - specifier: 0.3.23 - version: 0.3.23 + specifier: 0.3.24 + version: 0.3.24 reflect-metadata: specifier: 0.2.2 version: 0.2.2 @@ -82,110 +79,110 @@ importers: specifier: 7.0.2 version: 7.0.2 vitest: - specifier: 4.1.10 - version: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@26.1.2)) + specifier: 4.1.11 + version: 4.1.11(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(vite@8.1.5(@types/node@26.4.0)(yaml@2.9.0)) zod: specifier: 4.4.3 version: 4.4.3 packages: - '@ai-sdk/gateway@4.0.51': - resolution: {integrity: sha512-tQxWGUbg/O3A5EgekJo/C2byLVQ1r+vL6QdEVWmxUSnPhdCupcOeDbQO1tv9Um40VrdwYuSWlYzcbWvtlG5bOA==} + '@ai-sdk/gateway@4.0.67': + resolution: {integrity: sha512-LtyxLkg7dZ2iz8Ouh1806BJbA+q+FKc/mXUCl4v/wdNNIGtbfk80dNtlhqjhqOZa4dnfc3caVafRL7ocxzoegA==} engines: {node: '>=22'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider-utils@5.0.27': - resolution: {integrity: sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw==} + '@ai-sdk/provider-utils@5.0.32': + resolution: {integrity: sha512-MZUhlINn6FzKIWuX3T36h+yM9d7bG+yatH+kC99ZCe0DHxXfP73KwaoLiLcZDPQDamFyO3umPPBLJieZJyG4DQ==} engines: {node: '>=22'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider@4.0.7': - resolution: {integrity: sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q==} + '@ai-sdk/provider@4.0.8': + resolution: {integrity: sha512-aWO7iwhFUGf347tCwNGggggfmZigaSu7TF739IZSrWWABUp7zkb4Cr3fMqvBe5EIS7ABJJu3Cadn0g/zs1G0QQ==} engines: {node: '>=22'} - '@aws-sdk/checksums@3.1000.26': - resolution: {integrity: sha512-CGznePoL+1oWCSzqmkvlYMpWQEZohPR3LntjKXXfVH+oh6Kd8d+yHLkjpiAGwPIHAxFe4OAq3aKfRnv/wqWIyA==} + '@aws-sdk/checksums@3.1000.29': + resolution: {integrity: sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-s3@3.1103.0': - resolution: {integrity: sha512-FO7SB2vLhZRN3lBHVhMhRm3YKSHK8e917qjB8LMEEhU69cQ0Ahd/OMyezu+KqNANqEtyxfSnFVvYt81x6ZKGZA==} + '@aws-sdk/client-s3@3.1120.0': + resolution: {integrity: sha512-UunWgNl/U8wIlxVRyhUPqlozFkUS88UqXjAH50XfPDXaZeK9P7KVYTAh4KREVrLPQTzPxVSbQGog3gHlc/P6vg==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.977.6': - resolution: {integrity: sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw==} + '@aws-sdk/core@3.977.9': + resolution: {integrity: sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.67': - resolution: {integrity: sha512-rcIpk5kxUqDaaNa6Xk23pQ6ViY7jlqzmfFWCahQcBT97ddXaXYYwzCen9Tz1Jvo6aJft6wDl5bN44/Jw5B4oLA==} + '@aws-sdk/credential-provider-env@3.972.70': + resolution: {integrity: sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.69': - resolution: {integrity: sha512-nggwJtZ4eeNsUw5IeWBMXsi1ryct5idi0K+/SCRF3kybLubOMaNTb3XCihXpWMiVpyzyPeIrl0zTkzhBH9porA==} + '@aws-sdk/credential-provider-http@3.972.72': + resolution: {integrity: sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.973.12': - resolution: {integrity: sha512-pNEf/OeyN5X3VmLKlgSO6TqaWmW10CvI3TfwL1XhsuhYjSLT2VDaxFnCPHnOeQXSaFisMX4jNhpETriqN8DOmg==} + '@aws-sdk/credential-provider-ini@3.973.15': + resolution: {integrity: sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.74': - resolution: {integrity: sha512-0AQfDcf99TNmqVKv0owHrw/TQs6i4ZE5t9qmz6NvO53bE/sA/tpXhXL9AAcEP1qHc6Zzjd1UMb69+/9zdhvY3g==} + '@aws-sdk/credential-provider-login@3.972.77': + resolution: {integrity: sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.78': - resolution: {integrity: sha512-OgPAnfvbGAMWac6yvxJ1ihslrvDpPVwR68D2csospdNCCyPvHk9JLzYKwz48SNiS1T2znDwHauywRKRFfpyYng==} + '@aws-sdk/credential-provider-node@3.972.81': + resolution: {integrity: sha512-Rml+WitoFvXmv6JZ18U/xGdGDGGvB/mOin0ya0lTnTrdC0Z1lrVxTYh7iNklZBcvcRMrs4DoEf6xy1KWyrLQQw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.67': - resolution: {integrity: sha512-IlUEejorGTWKb4/Dm7K5Yw4QxUmXLThLhrvBmzVBqZFTbW72cv9LTcITmo1dsnYriALE4h68mOq4LB99x6sQ7Q==} + '@aws-sdk/credential-provider-process@3.972.70': + resolution: {integrity: sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.973.11': - resolution: {integrity: sha512-gAQBkBZxUB84d71+pPcI9L+jh2ujhuAVxc/4FgGiWFDjkPBlMKxzd5XDtkSXTFX8Ro7ansnT88+XadasxMeCRw==} + '@aws-sdk/credential-provider-sso@3.973.14': + resolution: {integrity: sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.73': - resolution: {integrity: sha512-SnlEmQa6SjOgs6iOPLUQl1Eyq4AKiAdPQlkOhFhqNfDtDCwibMGvL6QlkSmf3o6vAUSImzdPCxowT5dfQUZP1A==} + '@aws-sdk/credential-provider-web-identity@3.972.76': + resolution: {integrity: sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==} engines: {node: '>=20.0.0'} - '@aws-sdk/lib-storage@3.1103.0': - resolution: {integrity: sha512-Q3uRgn1XyAnevbCVPkSBe28qqO6AYuENhfov3rNyowV5iRvyed7sc6lEY9MWXeaeevrJBpEE35Shy5mW6k2zDg==} + '@aws-sdk/lib-storage@3.1120.0': + resolution: {integrity: sha512-Sg7NVSqnFnfiH5gX14Gna5Z3DaGoTm8tYxnjExdAhGaoXFthglSvLEcTYEHNdm5dwUQ2+HI8hT/zPGEF6+R4KQ==} engines: {node: '>=20.0.0'} peerDependencies: - '@aws-sdk/client-s3': ^3.1103.0 + '@aws-sdk/client-s3': ^3.1120.0 - '@aws-sdk/middleware-sdk-s3@3.972.72': - resolution: {integrity: sha512-lSAoVPvQxX1d8TOM6waKDBQrvvZcm4w6pCldFAsRUffEaXq6lYY0pPyew3KlLu6Xqb74DXI42hGvSsbGBLljlw==} + '@aws-sdk/middleware-sdk-s3@3.972.75': + resolution: {integrity: sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng==} engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.997.41': - resolution: {integrity: sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ==} + '@aws-sdk/nested-clients@3.997.44': + resolution: {integrity: sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==} engines: {node: '>=20.0.0'} - '@aws-sdk/s3-presigned-post@3.1103.0': - resolution: {integrity: sha512-qE5x9Ddmqm70TqA7O3x3KbCQMucmBe2f42F7FZhM4DD6si2b6iIu6yuiKqyDdS294KJnQwTTUCtAhonnmpbazQ==} + '@aws-sdk/s3-presigned-post@3.1120.0': + resolution: {integrity: sha512-XscUlDxgDVI/EnkrutpyVUo0CpSXDcUQs/CWWbkcHwbTBlFIRRL9mRwTZYuN2MvxiiMuH2YlNXZGStZhE8HuvQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/s3-request-presigner@3.1103.0': - resolution: {integrity: sha512-937UiasYudfKWsCU/+kAHJ4UMJa90l0UcMDLwPvZ2UBjhJ4Iz5mVxiZNowthsPmJ8wH+tHqTl/O1kOD6dMuIIg==} + '@aws-sdk/s3-request-presigner@3.1120.0': + resolution: {integrity: sha512-KDPKX7uFulSb6AmWloSQRmHOm1ad5c9+W76ljxDmL4rndCYX4mBDdHwdWXIVNaLn6gXrm7G/3CQyiEdPe69lwQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/signature-v4-multi-region@3.996.43': - resolution: {integrity: sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==} + '@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.1103.0': - resolution: {integrity: sha512-N4wy26MNn31ItGVHYHPrEuCIFY4MBBjC+C5v1lJKqIUSA7OZBdhleCY53zCCrXn27hsk7YNOaTuhQu807S4AfQ==} + '@aws-sdk/token-providers@3.1116.0': + resolution: {integrity: sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/types@3.974.2': - resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==} + '@aws-sdk/types@3.974.5': + resolution: {integrity: sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/xml-builder@3.972.37': - resolution: {integrity: sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==} + '@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': @@ -205,10 +202,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/runtime@7.29.7': - resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.8': resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} @@ -220,60 +213,74 @@ packages: '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} - '@changesets/apply-release-plan@7.1.1': - resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} + '@changesets/apply-release-plan@8.0.0': + resolution: {integrity: sha512-kUd2pbf1w5/AYmBMb0Tt+rkIPCjFJdT0SZMrkOjJT/WV/QbtmvkyB5jkV0oaNPheavprZk+SfUiozUti7TIL2w==} + engines: {node: ^22.11 || ^24 || >=26} - '@changesets/assemble-release-plan@6.0.10': - resolution: {integrity: sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A==} + '@changesets/assemble-release-plan@7.0.0': + resolution: {integrity: sha512-oEW8BxdA604kGGtDSCiHr5w9Tv4UWe9I2k61IBNZzCOE1kbYaJj4v+lFQNgcEZFkUc2pV/+hASErGDvpJOZCTg==} + engines: {node: ^22.11 || ^24 || >=26} - '@changesets/changelog-git@0.2.1': - resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==} + '@changesets/changelog-git@1.0.0': + resolution: {integrity: sha512-3Dst2Ime2Op5nd4XmWJLPIgp11ZFqJqSkVug9izK6TDcIV4YlhPS4ECbEVR+eGI0bk0r1ItogD4j2Oli87bJrA==} + engines: {node: ^22.11 || ^24 || >=26} - '@changesets/cli@2.31.1': - resolution: {integrity: sha512-uO05WTcRBwuVOJVSW8Cmpqw6q0WDL53ajGCMyszutvOe5toOnunbpM4jZzf+qxBOz7i0AzopZ8diBuewjmF40w==} + '@changesets/cli@3.0.1': + resolution: {integrity: sha512-3IVpRSgiKDj2aRbBHKtWTXSRH2/iR3bWxau9iQUoEsZjDhRIj9nhl90k7FWMxZAJvLgJBbgMq8+qS0xQsenYsQ==} + engines: {node: ^22.11 || ^24 || >=26, npm: '>=10.9.0', pnpm: '>=10.0.0', yarn: '>=4.5.2'} hasBin: true - '@changesets/config@3.1.4': - resolution: {integrity: sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q==} + '@changesets/config@4.0.0': + resolution: {integrity: sha512-mw95/YrkOuhZZxfnVAA4bSXOFUi+KlhzOBTM8C4x777NhUU6HWIl9Z+K+nME+E4PVsv5NQVQwTfiHihAS1A/ow==} + engines: {node: ^22.11 || ^24 || >=26} - '@changesets/errors@0.2.0': - resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==} + '@changesets/errors@1.0.0': + resolution: {integrity: sha512-ElN/mEzn6zmETgjwf5MclCMa9ef59sAR0lfO8VSYIsiRvbC2FbLB/92EoYw10Sl0kGixxHFiJZUSv7dA+YpR8g==} + engines: {node: ^22.11 || ^24 || >=26} - '@changesets/get-dependents-graph@2.1.4': - resolution: {integrity: sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg==} + '@changesets/format@0.1.2': + resolution: {integrity: sha512-Caez5XtNXCFS/G5bwyav3wuXL0tMxVd2ZGbaumWbzN08tyzO21asCw7JZhNtVsAZDCvDRUzZN+Iit9SyRITSYA==} + engines: {node: ^22.11 || ^24 || >=26} - '@changesets/get-release-plan@4.0.16': - resolution: {integrity: sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g==} + '@changesets/get-dependents-graph@3.0.0': + resolution: {integrity: sha512-ji/t5wFA1zREKXRUePE6Qi+Qu2UgxCeSSGQrphezwvQZrp49B7sJ+8+wvM0tA7zPeSxYKCojDy3WWgrl+s+awg==} + engines: {node: ^22.11 || ^24 || >=26} - '@changesets/get-version-range-type@0.4.0': - resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==} + '@changesets/git@4.0.0': + resolution: {integrity: sha512-uIEswpPUgzBBqrC0qg13byNaPorzhf05LE2T+gRizEKCXvMsJC6NPJp5iNDSV/gYj4Viqh09UiOF5E7XWeH5lQ==} + engines: {node: ^22.11 || ^24 || >=26} - '@changesets/git@3.0.4': - resolution: {integrity: sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==} + '@changesets/parse@1.0.0': + resolution: {integrity: sha512-P0iaMb9p9CRYZiTgAllEIF9AUMQHIy1G72tKlcIqJp61icZSsKQNiOPdxAMZG8m/DvZwt/oz5xEbTpike//dWg==} + engines: {node: ^22.11 || ^24 || >=26} - '@changesets/logger@0.1.1': - resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==} + '@changesets/pre@3.0.0': + resolution: {integrity: sha512-Zm/6YliV/a2oeWTqHJf6KxLrQwgcK1i/BRDl2m0EKZvbnxV5fG9QRhwJJGshjsZTUTS6dkfURQ2K6aAvgNw/3Q==} + engines: {node: ^22.11 || ^24 || >=26} - '@changesets/parse@0.4.3': - resolution: {integrity: sha512-ZDmNc53+dXdWEv7fqIUSgRQOLYoUom5Z40gmLgmATmYR9NbL6FJJHwakcCpzaeCy+1D0m0n7mT4jj2B/MQPl7A==} + '@changesets/read@1.0.0': + resolution: {integrity: sha512-8TdE2PwG6yArPt5Ozej83Z6iHz1G8BDBKvIEKZ455MccR4K3GNbLR2XqjBxa+5yLFnEd3xAOPXYboBg1pt8stQ==} + engines: {node: ^22.11 || ^24 || >=26} - '@changesets/pre@2.0.2': - resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==} + '@changesets/should-skip-package@1.0.0': + resolution: {integrity: sha512-pwqoJmbONn1XgXmZXPEExgAaT+HdZLjALFTDgIm+PnS5KeO2nLtzA2/Q+4aMFY14kFMuXKG30MObhvDzWgzDgg==} + engines: {node: ^22.11 || ^24 || >=26} - '@changesets/read@0.6.7': - resolution: {integrity: sha512-D1G4AUYGrBEk8vj8MGwf75k9GpN6XL3wg8i42P2jZZwFLXnlr2Pn7r9yuQNbaMCarP7ZQWNJbV6XLeysAIMhTA==} + '@changesets/types@7.0.0': + resolution: {integrity: sha512-c5GoiQyt3pxiXjrWSNoP8/GRf4kG+VnKzovx1OQM8dYYALlSwgedmkPmJ+ZqGxqwg9D3Bkj85Uo4KLd5BN3A0w==} + engines: {node: ^22.11 || ^24 || >=26} - '@changesets/should-skip-package@0.1.2': - resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==} + '@changesets/write@1.0.1': + resolution: {integrity: sha512-q/ThtP9gcnEP6xlv7LrY26C2mHqdGBti/MmOVngt/M/oIGYkssmQGxPK9WzBNt2juVcH/vml2WQ+ra8LXYOTaA==} + engines: {node: ^22.11 || ^24 || >=26} - '@changesets/types@4.1.0': - resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==} + '@clack/core@1.4.3': + resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==} + engines: {node: '>= 20.12.0'} - '@changesets/types@6.1.0': - resolution: {integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==} - - '@changesets/write@0.4.0': - resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@clack/prompts@1.7.0': + resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} + engines: {node: '>= 20.12.0'} '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} @@ -287,8 +294,8 @@ packages: '@fastify/ajv-compiler@4.0.5': resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} - '@fastify/cors@11.2.0': - resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} + '@fastify/cors@11.3.0': + resolution: {integrity: sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw==} '@fastify/error@4.2.0': resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} @@ -296,8 +303,8 @@ packages: '@fastify/fast-json-stringify-compiler@5.1.0': resolution: {integrity: sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==} - '@fastify/formbody@8.0.2': - resolution: {integrity: sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==} + '@fastify/formbody@9.0.0': + resolution: {integrity: sha512-T/af26CSrUARBCvsEmv+DJLPfZlrRKESzqironxP1j7qzuLyKcoZtj6MuTGShuKx1THXugoie2oFbUJxXfGFzA==} '@fastify/forwarded@3.0.2': resolution: {integrity: sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==} @@ -314,15 +321,6 @@ packages: peerDependencies: hono: ^4 - '@inquirer/external-editor@1.0.3': - resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -337,11 +335,17 @@ packages: resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} - '@manypkg/find-root@1.1.0': - resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} + '@manypkg/find-root@3.1.0': + resolution: {integrity: sha512-BcSqCyKhBVZ5YkSzOiheMCV41kqAFptW6xGqYSTjkVTl9XQpr+pqHhwgGCOHQtjDCv7Is6EFyA14Sm5GVbVABA==} + engines: {node: '>=20.0.0'} - '@manypkg/get-packages@1.1.3': - resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@manypkg/get-packages@3.1.0': + resolution: {integrity: sha512-0TbBVyvPrP7xGYBI/cP8UP+yl/z+HtbTttAD7FMAJgn/kXOTwh5/60TsqP9ZYY710forNfyV0N8P/IE/ujGZJg==} + engines: {node: '>=20.0.0'} + + '@manypkg/tools@2.1.2': + resolution: {integrity: sha512-6QEf6yqFbETdwGITKq57aYoPfX/3K8XFNwsAlx0C1M7o8cb79sv1M3w+tWuWvIcSbNqrLF7OD7YpZMVVz335hQ==} + engines: {node: '>=20.0.0'} '@modelcontextprotocol/sdk@1.30.0': resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} @@ -360,8 +364,8 @@ packages: '@emnapi/core': ^2.0.0-alpha.3 '@emnapi/runtime': ^2.0.0-alpha.3 - '@nestjs/common@12.0.0-alpha.5': - resolution: {integrity: sha512-MB2bFWi/GE5TYIR1Z6Y1gp7iRk4xm9+zOlFlV5+5T3j7LNA6AR25YqibUdbgRkAbpklrHIxxj/gEFAw9yAeIwQ==} + '@nestjs/common@12.0.1': + resolution: {integrity: sha512-v0zTaRCTV2K2xSnb3GnJoQKtaR6VxouJtm+P2gpr5w61dlXeWpXl1WVknCSzUqx2QwTV10tBkHETxvQvkf2Exg==} peerDependencies: class-transformer: '>=0.4.1' class-validator: '>=0.13.2' @@ -373,14 +377,14 @@ packages: class-validator: optional: true - '@nestjs/core@12.0.0-alpha.5': - resolution: {integrity: sha512-KVZIr3fIo3Cohb0H5+vM4ZfElyMtd7Ssl6aXaFaXnBoniIBf183TM0eU+xbEbekaTVlITgdzGbFhLX2CHigEAw==} + '@nestjs/core@12.0.1': + resolution: {integrity: sha512-rU6tAi8vDdyzHgN0iW0J4UJvziroOqzHqzlqL7phOSPizaXVEePfv+OUOsdJEOh0Qd14mslc3udOheLrAEcZow==} engines: {node: '>= 20'} peerDependencies: - '@nestjs/common': ^11.0.0 - '@nestjs/microservices': ^11.0.0 - '@nestjs/platform-express': ^11.0.0 - '@nestjs/websockets': ^11.0.0 + '@nestjs/common': ^12.0.0 + '@nestjs/microservices': ^12.0.0 + '@nestjs/platform-express': ^12.0.0 + '@nestjs/websockets': ^12.0.0 reflect-metadata: ^0.1.12 || ^0.2.0 rxjs: ^7.1.0 peerDependenciesMeta: @@ -391,32 +395,32 @@ packages: '@nestjs/websockets': optional: true - '@nestjs/platform-express@12.0.0-alpha.5': - resolution: {integrity: sha512-rHEmPGKPUo/KeReHEe1rxx0dYTlbZ3lhU1ATK80b9/U/LBQIZn1BOKm1/LC9LHi4bO5MyWtD8ZCtk/tKUm4w4w==} + '@nestjs/platform-express@12.0.1': + resolution: {integrity: sha512-nF8bRgROptMyixYBfiJUAsjj2o9k13zxAjUKYaqHTPk8mTSd+gk7hXz6NykXNrfHWmMEYC9RttQlMUdMHNTbuA==} peerDependencies: - '@nestjs/common': ^11.0.0 - '@nestjs/core': ^11.0.0 + '@nestjs/common': ^12.0.0 + '@nestjs/core': ^12.0.0 - '@nestjs/platform-fastify@12.0.0-alpha.5': - resolution: {integrity: sha512-8jj5j8QbDWWM5uEjQ0hiUzFnKK8H6j1TuSFL/76AnhtT2uDRgN948SorVSV8ZE5B5hxf99jP6puwGT4QyVUX5g==} + '@nestjs/platform-fastify@12.0.1': + resolution: {integrity: sha512-LqR0kOahuZAei3wJK+SJfHc9o9H/OSdA7gMSMaW+Kfjy6GtpTNPdfpHeq3w2PuiPgUGk2QIX/dqUAYntJ5arSw==} peerDependencies: - '@fastify/static': ^8.0.0 || ^9.0.0 - '@fastify/view': ^10.0.0 || ^11.0.0 - '@nestjs/common': ^11.0.0 - '@nestjs/core': ^11.0.0 + '@fastify/static': ^10.1.2 + '@fastify/view': ^10.0.0 || ^11.0.0 || ^12.0.0 + '@nestjs/common': ^12.0.0 + '@nestjs/core': ^12.0.0 peerDependenciesMeta: '@fastify/static': optional: true '@fastify/view': optional: true - '@nestjs/testing@12.0.0-alpha.5': - resolution: {integrity: sha512-S3BNtD6DUZR0wgXroGMzuR/E7R2BjvEnVewtTPwh2Vj5tLypHwTDM6hykup6J3eAXwkixQNZL5c4/4qkNq5A5w==} + '@nestjs/testing@12.0.1': + resolution: {integrity: sha512-prf3fsUaoKS6t04XcQt5aSTp68Po1GxUcbEBPv+3iqAef07hKQEO15r0jmmwA+rWZfONYT1UcV3YIylR++/tCg==} peerDependencies: - '@nestjs/common': ^11.0.0 - '@nestjs/core': ^11.0.0 - '@nestjs/microservices': ^11.0.0 - '@nestjs/platform-express': ^11.0.0 + '@nestjs/common': ^12.0.0 + '@nestjs/core': ^12.0.0 + '@nestjs/microservices': ^12.0.0 + '@nestjs/platform-express': ^12.0.0 peerDependenciesMeta: '@nestjs/microservices': optional: true @@ -427,144 +431,127 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - - '@nuxt/opencollective@0.4.1': - resolution: {integrity: sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==} - engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} - hasBin: true - '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} - '@oxlint/binding-android-arm-eabi@1.77.0': - resolution: {integrity: sha512-E06sKWS6PiI6HRxS1wyQg22HvApt01hI7fV+T3wUk3OSbaaP4a3hYGY/MIQDmASqCiRjBdpRQYkgMkqH82cWmQ==} + '@oxlint/binding-android-arm-eabi@1.80.0': + resolution: {integrity: sha512-RM3Plj+biQpxa5d1GOOX6ciDlcUROmm4OZ/pLTpitkQt2mJv4jhtY4cbgaetOm5UKWZe05/TGQ6o1Vl8EOHkrA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.77.0': - resolution: {integrity: sha512-NvsKz0KZxTp9cYWPLf+FXaSZwB3oO3peAjtukpOMBgse2vhQSoIIVqeO1yR0lEo/UcdZIDL18uq+kL0LzQ0ytA==} + '@oxlint/binding-android-arm64@1.80.0': + resolution: {integrity: sha512-YlO5JEf0Yr2bUUlu8O8daVcUxtcGGbcSmyV7E7nSbJbfAdxTE0PFPwgnIlw7wXJaTYjb+qs5hI5q3jxUkI7cAw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.77.0': - resolution: {integrity: sha512-bgjTn6nW4bQCFBvSvuHCpDD+sONvmpo4lGI4PxzMt1quBA+xYxhczk6RiCn3GZ9gY8uhaBbwhj9MdKGfu6T9DA==} + '@oxlint/binding-darwin-arm64@1.80.0': + resolution: {integrity: sha512-BULDOyO3AhsmdWfQeIUCykDt3dd7XZBGLhp1eIh56skRv01O+cNjNPwXMIbeW1x4+pxcln5if72wcRgViVo7PA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.77.0': - resolution: {integrity: sha512-aotaIttH1R6j1Rwhx0M0htgeZyGtVQqYNTVEYMN/UcgHPquGA6kmk9OyuDc3a2GKUQBC+3C3GVQCcrRPMYqAFA==} + '@oxlint/binding-darwin-x64@1.80.0': + resolution: {integrity: sha512-YJ4JzLw7N5TDSQFlA0hAQGHvnDZgyypm1yunObVWcWiF9KM7eGCJKYKLgTC2Fi/57OdnBhbj4OkzPGdFQJ6HyA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.77.0': - resolution: {integrity: sha512-nNx/wta7ksRAdYvq+l4AWjXkLxEXHALhENxjj2cYbQAIR4ybaA5L+hCbE63HOmft5czQ6ks+hb8vmEAnn7YGPg==} + '@oxlint/binding-freebsd-x64@1.80.0': + resolution: {integrity: sha512-AYUIk5QnL0s8oWAYsREZwkRYy1SupJTXALo93J1TgzHywxQtdM99FecRMQ87MXEdPQ0j1TmEpeeq3fGNkpvMqg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.77.0': - resolution: {integrity: sha512-tMLLjM7xXtzXisVCzkOTXNCy9bZVId2wteNwjohlFDR/jY6WagpEDA1c1wu4xRc20Hojaxj+V6DSR7gbKxijWA==} + '@oxlint/binding-linux-arm-gnueabihf@1.80.0': + resolution: {integrity: sha512-9hBZVANupQ89W9dXyE0n8doCyaW5pDyGn3y6XlIMPZ+rIKuyqkr3SNUXmVJIhuvUq0NBU3RBiSXXE69l4XI6KA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.77.0': - resolution: {integrity: sha512-MiAFDFaqR0tmHTAyo0YDcZ5hyLREdYw/RQhc2R3cbT+8O3tB+zqPM2th9TTQ+Uo3jn/embS+DO+HyX9ztCPkOQ==} + '@oxlint/binding-linux-arm-musleabihf@1.80.0': + resolution: {integrity: sha512-SvS2uKqzY+pbfuvAHzH4338R6Zwo805GAwrIMVvK1KxoOWCIjZUdfzTCvilD7z6JK91v011+zYMryabhDo2AsQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.77.0': - resolution: {integrity: sha512-/xqQ3B16i1T4cyt/9Mn+4CpzhUXoBXp7kVpIwzOXNFLj5JmK1bIjsbSnX296Gg8A/o7oDtKWikFgBx0SLwztkw==} + '@oxlint/binding-linux-arm64-gnu@1.80.0': + resolution: {integrity: sha512-tCLadyqRVL3pQTRPNg7cjXKvcvS4fbyXeQHhKk5BTJ1oftQln5/yIIWbu/Xom/DX41zv2P9QGt6+D/TtQVtY3A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.77.0': - resolution: {integrity: sha512-LSbwuRKiNCenPDcbARqAZ5RfBy7gmj7vOvfJRLeCDU3gFtSxWbhv/+VTlaUqzUhNj1gFLHB8h7ALnxa/Az6z6g==} + '@oxlint/binding-linux-arm64-musl@1.80.0': + resolution: {integrity: sha512-XfpCNRlOPcLlJl4Bn/FUhjqlR6BVavEykERBf/MV7YA9VZDa5g5znVqYhyviMafcxS9Pe/i/kPvHNO0U6svEHQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.77.0': - resolution: {integrity: sha512-QWdcH31mXEUe5Nq1s0CfCpceaKjIo9uZtwDjAuL681g1axf+5x8xrg/eXWaw//4NCxYZ4V4e5Hu5tvdR+pTBlg==} + '@oxlint/binding-linux-ppc64-gnu@1.80.0': + resolution: {integrity: sha512-3I4yMwcFG9NeO8ioY6JBBuKsIm5GL/x7MATt1S4tVWaxPu5HcJ+XnLUbcVBTxG8q2Wu56HSj+NmXQiVYb1lp6A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.77.0': - resolution: {integrity: sha512-GnOfYgJxbcElOiPZaDFDl406ONddwvOWk2jvAAAEjwAl4GofNoHF+/HHUIBYa6bFCArlcGPi0XjC4cU1pkgF/Q==} + '@oxlint/binding-linux-riscv64-gnu@1.80.0': + resolution: {integrity: sha512-E1wAKymkpe1/E8helzBKdm81OBOF+ezxRyXRMEuik3ZpWDER5CPOKZwF66RsdwW98uwZv8UTFremUQtC1CzdJA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.77.0': - resolution: {integrity: sha512-AyEMTUCf0xY+hHF+IxqXFQIX0yQOIR8ykpY0lJNOw9xYqOzUX8dyZfRvlG0RfXwuQn2eonf/8NrMmDSZJjdqsA==} + '@oxlint/binding-linux-riscv64-musl@1.80.0': + resolution: {integrity: sha512-+gLRGD4sIo3+VA++iham5UxD9tKSoJ/VOrROCEXIcknrYtQg6iIQgvjN0cpiRF7N6UYC7pJbvHJlDnMge5LRpQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.77.0': - resolution: {integrity: sha512-sPLzEcNvxd/oyVQ5oZo92CiHkFkpBeRop13E/P3TPY+hZfXHKCOWKI70TE2RYwMKFJDc20EMjH16L7NZICtKTw==} + '@oxlint/binding-linux-s390x-gnu@1.80.0': + resolution: {integrity: sha512-aR0PrzHj9leW3NmzBAAP4EzdoBNoJcs9sjnIQPIwyRnBGYrRbXUIpEB5Q39AqK3PLY5JK5uEhDQDiUa1QSAstw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.77.0': - resolution: {integrity: sha512-1Oh2ssH2L7lwyvkdSqaMUfsGfwU2Wfvew+obBUYjRVqhpBcUpwnsPSEr1IzVi9XqkuY10geiLsNKecqaZC34Dw==} + '@oxlint/binding-linux-x64-gnu@1.80.0': + resolution: {integrity: sha512-vSVh5cSo3Xxs6ghBCcFJlpbkbENzDog1qXtoXLa/HC3aCrR4XO76GZbXmQoCPHnu99nQpdCeC3H9tdNICfDh7A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.77.0': - resolution: {integrity: sha512-0j/2wRgNGO+Qj/M1uu/p57h/hFTTWWcfie0ufkbabeus2s5+/QqkCflnMOwLLN5m2GsNeWp4xdl4cPa4n7QCOQ==} + '@oxlint/binding-linux-x64-musl@1.80.0': + resolution: {integrity: sha512-FfzBXpNQ8u7/ZI/p8bl73MeZ508Ax3hxWp3SiJpEFiC+BB9XcXy5FAZHTLKDPSzrUpxQZSZJAVdDmuJp/+HDBQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.77.0': - resolution: {integrity: sha512-BJ/j54qS0usEnyDkLYURMj2iiD9h5Cyy+ppzeMSXBGRXaGRNWnj1Mw14NqWMR5E/PzdgB30OOCCzLzbRoduafw==} + '@oxlint/binding-openharmony-arm64@1.80.0': + resolution: {integrity: sha512-zMzbkumtmprCgRwoYNzcB3iC39fXdJIMLMU33KdCjEGLlJGOEt1+LwQ4LF8ndLzAEKVz4BR0y3V6Xrkk3Nm3yA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.77.0': - resolution: {integrity: sha512-Yh8w+g2Lpx7StrvtYkoz9JJvXjB9wxgFChFNb85nrXm/wj/XTwGWS1hve9+900HL7llrntYB3YP+y32E3tRqzA==} + '@oxlint/binding-win32-arm64-msvc@1.80.0': + resolution: {integrity: sha512-ib6iRcrXsk4t1fm3iKcwksyWh1ZkZXC/2mEzakl0ai2+6HZunf1WWMZ/xP9EJAvw9g9K4UVTC3NF/+G2qLrbTQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.77.0': - resolution: {integrity: sha512-zja5b7+6a7UsRFgAQSrnax5vrzliEyNPLCjfXONu/vTWswaIVZGFajJZptaeRvPE4LghtFdAzVFlexTm7MVTGA==} + '@oxlint/binding-win32-ia32-msvc@1.80.0': + resolution: {integrity: sha512-xhRWBMpLxZvgKAH6+DJZmpP+W8Y8UdQOSU1JfxSWNXsaBaRGW77j+1hCuNHlzj7OH4SPN8fYd1q0o2qrDtoVyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.77.0': - resolution: {integrity: sha512-+teyvPDZ2RjUvo+SuCqS/UhaJl1QtdW5fWT5NJTV61V5MIuIS90Db9LixmtEGvXixyttiK62P96MSu3UlpviBw==} + '@oxlint/binding-win32-x64-msvc@1.80.0': + resolution: {integrity: sha512-yAnO7lwBYQnz2pcfBPIGQQZWIX5zd5R/1aAKIF3oE+TVj7IhoHcROjOkz3sRDngzqhfPKfFaXqug5j5rE5dn6Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -575,8 +562,12 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@publint/pack@0.1.6': - resolution: {integrity: sha512-3uVNyGcVplhPZSLVyeIpL7+cIRn1YCSNHLG/rUIlBQMVH8YuN9++YF+5+UDIIO9RW98dujiUoTltO7RDB5bFJA==} + '@pnpm/deps.graph-sequencer@1100.0.1': + resolution: {integrity: sha512-pOr5+q1fLYKwFN3LAJuGZEnfXDcQ73zqgDHMtGy+K+uIoUqyY+6MeDCWFwfu+4EFuq76I5EPFofoNAI+Bmmq4A==} + engines: {node: '>=22.13'} + + '@publint/pack@0.1.7': + resolution: {integrity: sha512-4EDEmvxWtgsCnnVeBvtFIFZtUhPPt1+bA9JrSwU4Sa//6oKtzCSlGGXYJr44OD9aGISymbieJ4mCKHUygUDU+g==} engines: {node: '>=18'} '@rolldown/binding-android-arm64@1.1.5': @@ -677,28 +668,28 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@smithy/core@3.31.1': - resolution: {integrity: sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==} + '@smithy/core@3.33.3': + resolution: {integrity: sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==} engines: {node: '>=18.0.0'} '@smithy/credential-provider-imds@4.4.16': resolution: {integrity: sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==} engines: {node: '>=18.0.0'} - '@smithy/fetch-http-handler@5.6.13': - resolution: {integrity: sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==} + '@smithy/fetch-http-handler@5.7.2': + resolution: {integrity: sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==} engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.9.13': - resolution: {integrity: sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==} + '@smithy/node-http-handler@4.11.3': + resolution: {integrity: sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA==} engines: {node: '>=18.0.0'} '@smithy/signature-v4@5.6.12': resolution: {integrity: sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==} engines: {node: '>=18.0.0'} - '@smithy/types@4.16.1': - resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} + '@smithy/types@4.17.2': + resolution: {integrity: sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==} engines: {node: '>=18.0.0'} '@standard-schema/spec@1.1.0': @@ -729,11 +720,11 @@ packages: '@types/methods@1.1.4': resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} - '@types/node@12.20.55': - resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + '@types/node@26.3.0': + resolution: {integrity: sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==} - '@types/node@26.1.2': - resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + '@types/node@26.4.0': + resolution: {integrity: sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==} '@types/superagent@8.1.11': resolution: {integrity: sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==} @@ -865,20 +856,20 @@ packages: resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} engines: {node: '>= 20'} - '@vitest/coverage-v8@4.1.10': - resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + '@vitest/coverage-v8@4.1.11': + resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} peerDependencies: - '@vitest/browser': 4.1.10 - vitest: 4.1.10 + '@vitest/browser': 4.1.11 + vitest: 4.1.11 peerDependenciesMeta: '@vitest/browser': optional: true - '@vitest/expect@4.1.10': - resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} - '@vitest/mocker@4.1.10': - resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -888,20 +879,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.10': - resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} - '@vitest/runner@4.1.10': - resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} - '@vitest/snapshot@4.1.10': - resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} - '@vitest/spy@4.1.10': - resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} - '@vitest/utils@4.1.10': - resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} '@workflow/serde@4.1.0': resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==} @@ -913,8 +904,8 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} - ai@7.0.64: - resolution: {integrity: sha512-29Ufm56e53pRzIPueWzZiwJsgxmZStaBFpwEkVM89sqyIIax/pzDm+ez/ksJ/CcY9UCuYzvCQpE2zkFHICvS3w==} + ai@7.0.83: + resolution: {integrity: sha512-bg7+SopUwqA7DeQ2O8I9qELyQTHCeeI/0RuNUlT/gGz+LqWrIl5vbYRQv3eMBEnUsVFaM33n6SA8Vqe9gi8L1w==} engines: {node: '>=22'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -930,27 +921,9 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - ansi-colors@4.1.3: - resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} - engines: {node: '>=6'} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - append-field@1.0.0: resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - array-union@2.1.0: - resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} - engines: {node: '>=8'} - asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} @@ -977,10 +950,6 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - better-path-resolve@1.0.0: - resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} - engines: {node: '>=4'} - body-parser@2.3.0: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} @@ -988,10 +957,6 @@ packages: bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1006,6 +971,10 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1018,9 +987,6 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} - chardet@2.2.0: - resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} - combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -1036,10 +1002,6 @@ packages: resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} engines: {'0': node >= 6.0} - consola@3.4.2: - resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} - engines: {node: ^14.18.0 || >=16.10.0} - content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -1099,10 +1061,6 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} - detect-indent@6.1.0: - resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} - engines: {node: '>=8'} - detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1110,10 +1068,6 @@ packages: dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} - dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} - dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1125,10 +1079,6 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} - enquirer@2.4.1: - resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} - engines: {node: '>=8.6'} - es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -1151,11 +1101,6 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -1189,22 +1134,12 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} - extendable-error@0.1.7: - resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} - fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - - fast-json-stringify@6.4.0: - resolution: {integrity: sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==} - fast-json-stringify@7.0.1: resolution: {integrity: sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==} @@ -1214,20 +1149,26 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.5: resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} fast-uri@4.1.2: resolution: {integrity: sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==} - fastify-plugin@5.1.0: - resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} - fastify@5.11.2: - resolution: {integrity: sha512-i/eJG7nXR9OkbFgoX4jFiPOHoRq0rXqDACVAXELh5Fdg6BFBErIVZ8MyibGCwup9Go1pYrxZJ0ogIub8vVdEcQ==} + fastify-plugin@6.0.0: + resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==} - fastify@5.8.5: - resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} + fastify@5.12.1: + resolution: {integrity: sha512-FWi+tQvwxR/PeRX7Z2mhfEF5ozJ3jn9asiiclzKXNSzJRHAYcU924aIOKAdHFJ+YIKieh3cqr1IwCOvTr41B3Q==} fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1241,16 +1182,19 @@ packages: picomatch: optional: true - file-type@21.3.4: - resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} - engines: {node: '>=20'} + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + + file-type@22.0.2: + resolution: {integrity: sha512-0H8TsCUGBLx+V5adH3EY52hTAcyLKbV1D4gq5cIOJ6DnQAHeV9Z2Hhuc5CoBX4YmvB2oL+JIC84z0qO7JsCoNw==} + engines: {node: '>=22'} - files-sdk@2.2.3: - resolution: {integrity: sha512-1xCCh5yBhNwVfpeLKSaU7ejmh2kesw9mnU9dus0rw9EzNuNmcIuECZ91DlD0YsEP5ojXlPaPB8G7pQ25tqdebQ==} + files-sdk@2.3.0: + resolution: {integrity: sha512-YOlRvcYPgrVf23Q7+E5rj7NTRwmym6VZG3vbr87HMS6jYluf2wd1zA9XCjJgtm1lTTmLzbJgs5UHLXl8CYOBJQ==} hasBin: true peerDependencies: '@anthropic-ai/claude-agent-sdk': ^0.3.0 - '@aws-sdk/client-s3': ^3.700.0 + '@aws-sdk/client-s3': ^3.1079.0 '@aws-sdk/lib-storage': ^3.700.0 '@aws-sdk/s3-presigned-post': ^3.700.0 '@aws-sdk/s3-request-presigner': ^3.700.0 @@ -1389,25 +1333,17 @@ packages: zod: optional: true - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} - find-my-way@9.6.0: - resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} - engines: {node: '>=20'} - find-my-way@9.7.0: resolution: {integrity: sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==} engines: {node: '>=20'} - find-up@4.1.0: - resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} - engines: {node: '>=8'} + find-my-way@9.9.0: + resolution: {integrity: sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==} + engines: {node: '>=20'} form-data@4.0.6: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} @@ -1425,14 +1361,6 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} - fs-extra@7.0.1: - resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} - engines: {node: '>=6 <7 || >=8'} - - fs-extra@8.1.0: - resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} - engines: {node: '>=6 <7 || >=8'} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1449,21 +1377,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - - globby@11.1.0: - resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} - engines: {node: '>=10'} - gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -1502,9 +1419,8 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -1521,29 +1437,9 @@ packages: resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} engines: {node: '>= 10'} - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} - is-subdir@1.2.0: - resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} - engines: {node: '>=4'} - - is-windows@1.0.2: - resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} - engines: {node: '>=0.10.0'} - isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1563,20 +1459,15 @@ packages: resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} engines: {node: '>=6'} + jju@1.4.0: + resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + jose@6.2.8: resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==} js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} - js-yaml@3.15.1: - resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==} - hasBin: true - - js-yaml@4.3.1: - resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} - hasBin: true - json-schema-ref-resolver@3.0.0: resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} @@ -1589,8 +1480,11 @@ packages: json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} - jsonfile@4.0.0: - resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + launch-editor@2.14.1: + resolution: {integrity: sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==} light-my-request@6.6.0: resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} @@ -1673,13 +1567,6 @@ packages: resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} engines: {node: '>=13.2.0'} - locate-path@5.0.0: - resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} - engines: {node: '>=8'} - - lodash.startcase@4.4.0: - resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1706,18 +1593,10 @@ packages: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - methods@1.1.2: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -1739,6 +1618,11 @@ packages: engines: {node: '>=4.0.0'} hasBin: true + mime@4.1.0: + resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} + engines: {node: '>=16'} + hasBin: true + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -1782,11 +1666,8 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - outdent@0.5.0: - resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} - - oxlint@1.77.0: - resolution: {integrity: sha512-qnGh8XJHaQ0dprrDXNQZgS0FgjI6v+V3+X8DwmaV++5Aamy6jGKfDdQ1TUvhUxtmKFAbEf4/WeO5QZX+5WSngg==} + oxlint@1.80.0: + resolution: {integrity: sha512-5nTiSps4qdbCWLbxzuO00alHkEO2exR9YMN/ig6QXWrLsYSG0KaObOAM+l6oU2LcKPWoSAGYbkZIGEu1ViiWKA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -1798,28 +1679,9 @@ packages: vite-plus: optional: true - p-filter@2.1.0: - resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} - engines: {node: '>=8'} - - p-limit@2.3.0: - resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} - engines: {node: '>=6'} - - p-locate@4.1.0: - resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} - engines: {node: '>=8'} - - p-map@2.1.0: - resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} - engines: {node: '>=6'} - - p-try@2.2.0: - resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} - engines: {node: '>=6'} - - package-manager-detector@0.2.11: - resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + p-map@7.0.6: + resolution: {integrity: sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg==} + engines: {node: '>=18'} package-manager-detector@1.8.0: resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} @@ -1828,10 +1690,6 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -1839,28 +1697,16 @@ packages: path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} - path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} - pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} - picomatch@4.0.5: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} - pify@4.0.1: - resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} - engines: {node: '>=6'} - pino-abstract-transport@3.0.0: resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} @@ -1879,11 +1725,6 @@ packages: resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} - prettier@2.8.8: - resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} - engines: {node: '>=10.13.0'} - hasBin: true - prettier@3.9.6: resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} engines: {node: '>=14'} @@ -1899,8 +1740,8 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - publint@0.3.23: - resolution: {integrity: sha512-5MQipUPcB7MWw84zLUkHrg/H/UBtk3LL+A0GngTTBSsiNJLQurMUaSIRG3edlOrRz4UFe0AOKK9TZdIWviV+jQ==} + publint@0.3.24: + resolution: {integrity: sha512-9zS56KrKBoqi5Qt8h92uMP8TTM9AYZSgnmCo4u2priMqkOZvQnTsziZ2p5LJ2ywbYkAjoCDp2jda9u4cgFefIw==} engines: {node: '>=18'} hasBin: true @@ -1908,12 +1749,6 @@ packages: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} - quansync@0.2.11: - resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} - - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} @@ -1925,10 +1760,6 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} - read-yaml-file@1.1.0: - resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} - engines: {node: '>=6'} - readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -1947,10 +1778,6 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - ret@0.5.0: resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} engines: {node: '>=10'} @@ -1971,9 +1798,6 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} @@ -2025,6 +1849,10 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shell-quote@1.10.0: + resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} + engines: {node: '>= 0.4'} + side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -2044,13 +1872,8 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -2059,16 +1882,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - spawndamnit@3.0.1: - resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} - split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -2089,14 +1906,6 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-bom@3.0.0: - resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} - engines: {node: '>=4'} - strtok3@10.3.5: resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} engines: {node: '>=18'} @@ -2113,10 +1922,6 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} - term-size@2.2.1: - resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} - engines: {node: '>=8'} - thread-stream@4.2.0: resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} engines: {node: '>=20'} @@ -2136,10 +1941,6 @@ packages: resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} engines: {node: '>=14.0.0'} - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - toad-cache@3.7.4: resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==} engines: {node: '>=20'} @@ -2186,10 +1987,6 @@ packages: resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} - universalify@0.1.2: - resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} - engines: {node: '>= 4.0.0'} - unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -2244,20 +2041,20 @@ packages: yaml: optional: true - vitest@4.1.10: - resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.10 - '@vitest/browser-preview': 4.1.10 - '@vitest/browser-webdriverio': 4.1.10 - '@vitest/coverage-istanbul': 4.1.10 - '@vitest/coverage-v8': 4.1.10 - '@vitest/ui': 4.1.10 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -2298,6 +2095,11 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -2308,216 +2110,216 @@ packages: snapshots: - '@ai-sdk/gateway@4.0.51(zod@4.4.3)': + '@ai-sdk/gateway@4.0.67(zod@4.4.3)': dependencies: - '@ai-sdk/provider': 4.0.7 - '@ai-sdk/provider-utils': 5.0.27(zod@4.4.3) + '@ai-sdk/provider': 4.0.8 + '@ai-sdk/provider-utils': 5.0.32(zod@4.4.3) '@vercel/oidc': 3.2.0 zod: 4.4.3 - '@ai-sdk/provider-utils@5.0.27(zod@4.4.3)': + '@ai-sdk/provider-utils@5.0.32(zod@4.4.3)': dependencies: - '@ai-sdk/provider': 4.0.7 + '@ai-sdk/provider': 4.0.8 '@standard-schema/spec': 1.1.0 '@workflow/serde': 4.1.0 eventsource-parser: 3.1.0 undici: 7.29.0 zod: 4.4.3 - '@ai-sdk/provider@4.0.7': + '@ai-sdk/provider@4.0.8': dependencies: json-schema: 0.4.0 - '@aws-sdk/checksums@3.1000.26': + '@aws-sdk/checksums@3.1000.29': dependencies: - '@aws-sdk/core': 3.977.6 - '@aws-sdk/types': 3.974.2 - '@smithy/core': 3.31.1 - '@smithy/types': 4.16.1 + '@aws-sdk/core': 3.977.9 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/client-s3@3.1103.0': - dependencies: - '@aws-sdk/checksums': 3.1000.26 - '@aws-sdk/core': 3.977.6 - '@aws-sdk/credential-provider-node': 3.972.78 - '@aws-sdk/middleware-sdk-s3': 3.972.72 - '@aws-sdk/signature-v4-multi-region': 3.996.43 - '@aws-sdk/types': 3.974.2 - '@smithy/core': 3.31.1 - '@smithy/fetch-http-handler': 5.6.13 - '@smithy/node-http-handler': 4.9.13 - '@smithy/types': 4.16.1 + '@aws-sdk/client-s3@3.1120.0': + dependencies: + '@aws-sdk/checksums': 3.1000.29 + '@aws-sdk/core': 3.977.9 + '@aws-sdk/credential-provider-node': 3.972.81 + '@aws-sdk/middleware-sdk-s3': 3.972.75 + '@aws-sdk/signature-v4-multi-region': 3.996.46 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/fetch-http-handler': 5.7.2 + '@smithy/node-http-handler': 4.11.3 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/core@3.977.6': + '@aws-sdk/core@3.977.9': dependencies: - '@aws-sdk/types': 3.974.2 - '@aws-sdk/xml-builder': 3.972.37 + '@aws-sdk/types': 3.974.5 + '@aws-sdk/xml-builder': 3.972.40 '@aws/lambda-invoke-store': 0.3.0 - '@smithy/core': 3.31.1 + '@smithy/core': 3.33.3 '@smithy/signature-v4': 5.6.12 - '@smithy/types': 4.16.1 + '@smithy/types': 4.17.2 bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.67': + '@aws-sdk/credential-provider-env@3.972.70': dependencies: - '@aws-sdk/core': 3.977.6 - '@aws-sdk/types': 3.974.2 - '@smithy/core': 3.31.1 - '@smithy/types': 4.16.1 + '@aws-sdk/core': 3.977.9 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.69': + '@aws-sdk/credential-provider-http@3.972.72': dependencies: - '@aws-sdk/core': 3.977.6 - '@aws-sdk/types': 3.974.2 - '@smithy/core': 3.31.1 - '@smithy/fetch-http-handler': 5.6.13 - '@smithy/node-http-handler': 4.9.13 - '@smithy/types': 4.16.1 + '@aws-sdk/core': 3.977.9 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/fetch-http-handler': 5.7.2 + '@smithy/node-http-handler': 4.11.3 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.973.12': - dependencies: - '@aws-sdk/core': 3.977.6 - '@aws-sdk/credential-provider-env': 3.972.67 - '@aws-sdk/credential-provider-http': 3.972.69 - '@aws-sdk/credential-provider-login': 3.972.74 - '@aws-sdk/credential-provider-process': 3.972.67 - '@aws-sdk/credential-provider-sso': 3.973.11 - '@aws-sdk/credential-provider-web-identity': 3.972.73 - '@aws-sdk/nested-clients': 3.997.41 - '@aws-sdk/types': 3.974.2 - '@smithy/core': 3.31.1 + '@aws-sdk/credential-provider-ini@3.973.15': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/credential-provider-env': 3.972.70 + '@aws-sdk/credential-provider-http': 3.972.72 + '@aws-sdk/credential-provider-login': 3.972.77 + '@aws-sdk/credential-provider-process': 3.972.70 + '@aws-sdk/credential-provider-sso': 3.973.14 + '@aws-sdk/credential-provider-web-identity': 3.972.76 + '@aws-sdk/nested-clients': 3.997.44 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 '@smithy/credential-provider-imds': 4.4.16 - '@smithy/types': 4.16.1 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-login@3.972.74': + '@aws-sdk/credential-provider-login@3.972.77': dependencies: - '@aws-sdk/core': 3.977.6 - '@aws-sdk/nested-clients': 3.997.41 - '@aws-sdk/types': 3.974.2 - '@smithy/core': 3.31.1 - '@smithy/types': 4.16.1 + '@aws-sdk/core': 3.977.9 + '@aws-sdk/nested-clients': 3.997.44 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-node@3.972.78': + '@aws-sdk/credential-provider-node@3.972.81': dependencies: - '@aws-sdk/credential-provider-env': 3.972.67 - '@aws-sdk/credential-provider-http': 3.972.69 - '@aws-sdk/credential-provider-ini': 3.973.12 - '@aws-sdk/credential-provider-process': 3.972.67 - '@aws-sdk/credential-provider-sso': 3.973.11 - '@aws-sdk/credential-provider-web-identity': 3.972.73 - '@aws-sdk/types': 3.974.2 - '@smithy/core': 3.31.1 + '@aws-sdk/credential-provider-env': 3.972.70 + '@aws-sdk/credential-provider-http': 3.972.72 + '@aws-sdk/credential-provider-ini': 3.973.15 + '@aws-sdk/credential-provider-process': 3.972.70 + '@aws-sdk/credential-provider-sso': 3.973.14 + '@aws-sdk/credential-provider-web-identity': 3.972.76 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 '@smithy/credential-provider-imds': 4.4.16 - '@smithy/types': 4.16.1 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-process@3.972.67': + '@aws-sdk/credential-provider-process@3.972.70': dependencies: - '@aws-sdk/core': 3.977.6 - '@aws-sdk/types': 3.974.2 - '@smithy/core': 3.31.1 - '@smithy/types': 4.16.1 + '@aws-sdk/core': 3.977.9 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.973.11': + '@aws-sdk/credential-provider-sso@3.973.14': dependencies: - '@aws-sdk/core': 3.977.6 - '@aws-sdk/nested-clients': 3.997.41 - '@aws-sdk/token-providers': 3.1103.0 - '@aws-sdk/types': 3.974.2 - '@smithy/core': 3.31.1 - '@smithy/types': 4.16.1 + '@aws-sdk/core': 3.977.9 + '@aws-sdk/nested-clients': 3.997.44 + '@aws-sdk/token-providers': 3.1116.0 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-web-identity@3.972.73': + '@aws-sdk/credential-provider-web-identity@3.972.76': dependencies: - '@aws-sdk/core': 3.977.6 - '@aws-sdk/nested-clients': 3.997.41 - '@aws-sdk/types': 3.974.2 - '@smithy/core': 3.31.1 - '@smithy/types': 4.16.1 + '@aws-sdk/core': 3.977.9 + '@aws-sdk/nested-clients': 3.997.44 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/lib-storage@3.1103.0(@aws-sdk/client-s3@3.1103.0)': + '@aws-sdk/lib-storage@3.1120.0(@aws-sdk/client-s3@3.1120.0)': dependencies: - '@aws-sdk/client-s3': 3.1103.0 - '@smithy/core': 3.31.1 - '@smithy/types': 4.16.1 + '@aws-sdk/client-s3': 3.1120.0 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 buffer: 5.6.0 events: 3.3.0 stream-browserify: 3.0.0 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-s3@3.972.72': + '@aws-sdk/middleware-sdk-s3@3.972.75': dependencies: - '@aws-sdk/core': 3.977.6 - '@aws-sdk/signature-v4-multi-region': 3.996.43 - '@aws-sdk/types': 3.974.2 - '@smithy/core': 3.31.1 - '@smithy/types': 4.16.1 + '@aws-sdk/core': 3.977.9 + '@aws-sdk/signature-v4-multi-region': 3.996.46 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.997.41': + '@aws-sdk/nested-clients@3.997.44': dependencies: - '@aws-sdk/core': 3.977.6 - '@aws-sdk/signature-v4-multi-region': 3.996.43 - '@aws-sdk/types': 3.974.2 - '@smithy/core': 3.31.1 - '@smithy/fetch-http-handler': 5.6.13 - '@smithy/node-http-handler': 4.9.13 - '@smithy/types': 4.16.1 + '@aws-sdk/core': 3.977.9 + '@aws-sdk/signature-v4-multi-region': 3.996.46 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/fetch-http-handler': 5.7.2 + '@smithy/node-http-handler': 4.11.3 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/s3-presigned-post@3.1103.0': + '@aws-sdk/s3-presigned-post@3.1120.0': dependencies: - '@aws-sdk/client-s3': 3.1103.0 - '@aws-sdk/core': 3.977.6 - '@aws-sdk/types': 3.974.2 - '@smithy/core': 3.31.1 + '@aws-sdk/client-s3': 3.1120.0 + '@aws-sdk/core': 3.977.9 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 '@smithy/signature-v4': 5.6.12 - '@smithy/types': 4.16.1 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/s3-request-presigner@3.1103.0': + '@aws-sdk/s3-request-presigner@3.1120.0': dependencies: - '@aws-sdk/core': 3.977.6 - '@aws-sdk/signature-v4-multi-region': 3.996.43 - '@aws-sdk/types': 3.974.2 - '@smithy/core': 3.31.1 - '@smithy/types': 4.16.1 + '@aws-sdk/core': 3.977.9 + '@aws-sdk/signature-v4-multi-region': 3.996.46 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.996.43': + '@aws-sdk/signature-v4-multi-region@3.996.46': dependencies: - '@aws-sdk/types': 3.974.2 + '@aws-sdk/types': 3.974.5 '@smithy/signature-v4': 5.6.12 - '@smithy/types': 4.16.1 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1103.0': + '@aws-sdk/token-providers@3.1116.0': dependencies: - '@aws-sdk/core': 3.977.6 - '@aws-sdk/nested-clients': 3.997.41 - '@aws-sdk/types': 3.974.2 - '@smithy/core': 3.31.1 - '@smithy/types': 4.16.1 + '@aws-sdk/core': 3.977.9 + '@aws-sdk/nested-clients': 3.997.44 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/types@3.974.2': + '@aws-sdk/types@3.974.5': dependencies: - '@smithy/types': 4.16.1 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.37': + '@aws-sdk/xml-builder@3.972.40': dependencies: - '@smithy/types': 4.16.1 + '@smithy/types': 4.17.2 tslib: 2.8.1 '@aws/lambda-invoke-store@0.3.0': {} @@ -2530,8 +2332,6 @@ snapshots: dependencies: '@babel/types': 7.29.8 - '@babel/runtime@7.29.7': {} - '@babel/types@7.29.8': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -2541,148 +2341,121 @@ snapshots: '@borewit/text-codec@0.2.2': {} - '@changesets/apply-release-plan@7.1.1': - dependencies: - '@changesets/config': 3.1.4 - '@changesets/get-version-range-type': 0.4.0 - '@changesets/git': 3.0.4 - '@changesets/should-skip-package': 0.1.2 - '@changesets/types': 6.1.0 - '@manypkg/get-packages': 1.1.3 - detect-indent: 6.1.0 - fs-extra: 7.0.1 - lodash.startcase: 4.4.0 - outdent: 0.5.0 - prettier: 2.8.8 - resolve-from: 5.0.0 + '@changesets/apply-release-plan@8.0.0': + dependencies: + '@changesets/config': 4.0.0 + '@changesets/format': 0.1.2 + '@changesets/git': 4.0.0 + '@changesets/should-skip-package': 1.0.0 + '@changesets/types': 7.0.0 + import-meta-resolve: 4.2.0 + jsonc-parser: 3.3.1 semver: 7.8.5 - '@changesets/assemble-release-plan@6.0.10': + '@changesets/assemble-release-plan@7.0.0': dependencies: - '@changesets/errors': 0.2.0 - '@changesets/get-dependents-graph': 2.1.4 - '@changesets/should-skip-package': 0.1.2 - '@changesets/types': 6.1.0 - '@manypkg/get-packages': 1.1.3 + '@changesets/errors': 1.0.0 + '@changesets/get-dependents-graph': 3.0.0 + '@changesets/should-skip-package': 1.0.0 + '@changesets/types': 7.0.0 semver: 7.8.5 - '@changesets/changelog-git@0.2.1': - dependencies: - '@changesets/types': 6.1.0 - - '@changesets/cli@2.31.1(@types/node@26.1.2)': - dependencies: - '@changesets/apply-release-plan': 7.1.1 - '@changesets/assemble-release-plan': 6.0.10 - '@changesets/changelog-git': 0.2.1 - '@changesets/config': 3.1.4 - '@changesets/errors': 0.2.0 - '@changesets/get-dependents-graph': 2.1.4 - '@changesets/get-release-plan': 4.0.16 - '@changesets/git': 3.0.4 - '@changesets/logger': 0.1.1 - '@changesets/pre': 2.0.2 - '@changesets/read': 0.6.7 - '@changesets/should-skip-package': 0.1.2 - '@changesets/types': 6.1.0 - '@changesets/write': 0.4.0 - '@inquirer/external-editor': 1.0.3(@types/node@26.1.2) - '@manypkg/get-packages': 1.1.3 - ansi-colors: 4.1.3 - enquirer: 2.4.1 - fs-extra: 7.0.1 - mri: 1.2.0 - package-manager-detector: 0.2.11 - picocolors: 1.1.1 - resolve-from: 5.0.0 + '@changesets/changelog-git@1.0.0': + dependencies: + '@changesets/types': 7.0.0 + + '@changesets/cli@3.0.1': + dependencies: + '@changesets/apply-release-plan': 8.0.0 + '@changesets/assemble-release-plan': 7.0.0 + '@changesets/changelog-git': 1.0.0 + '@changesets/config': 4.0.0 + '@changesets/errors': 1.0.0 + '@changesets/get-dependents-graph': 3.0.0 + '@changesets/git': 4.0.0 + '@changesets/pre': 3.0.0 + '@changesets/read': 1.0.0 + '@changesets/should-skip-package': 1.0.0 + '@changesets/types': 7.0.0 + '@changesets/write': 1.0.1 + '@clack/prompts': 1.7.0 + '@manypkg/get-packages': 3.1.0 + '@pnpm/deps.graph-sequencer': 1100.0.1 + cac: 7.0.0 + import-meta-resolve: 4.2.0 + launch-editor: 2.14.1 + package-manager-detector: 1.8.0 semver: 7.8.5 - spawndamnit: 3.0.1 - term-size: 2.2.1 - transitivePeerDependencies: - - '@types/node' + tinyexec: 1.3.0 - '@changesets/config@3.1.4': + '@changesets/config@4.0.0': dependencies: - '@changesets/errors': 0.2.0 - '@changesets/get-dependents-graph': 2.1.4 - '@changesets/logger': 0.1.1 - '@changesets/should-skip-package': 0.1.2 - '@changesets/types': 6.1.0 - '@manypkg/get-packages': 1.1.3 - fs-extra: 7.0.1 - micromatch: 4.0.8 + '@changesets/get-dependents-graph': 3.0.0 + '@changesets/should-skip-package': 1.0.0 + '@changesets/types': 7.0.0 + '@manypkg/get-packages': 3.1.0 + picomatch: 4.0.5 + + '@changesets/errors@1.0.0': {} - '@changesets/errors@0.2.0': + '@changesets/format@0.1.2': dependencies: - extendable-error: 0.1.7 + package-manager-detector: 1.8.0 + tinyexec: 1.3.0 - '@changesets/get-dependents-graph@2.1.4': + '@changesets/get-dependents-graph@3.0.0': dependencies: - '@changesets/types': 6.1.0 - '@manypkg/get-packages': 1.1.3 - picocolors: 1.1.1 + '@changesets/types': 7.0.0 semver: 7.8.5 - '@changesets/get-release-plan@4.0.16': + '@changesets/git@4.0.0': dependencies: - '@changesets/assemble-release-plan': 6.0.10 - '@changesets/config': 3.1.4 - '@changesets/pre': 2.0.2 - '@changesets/read': 0.6.7 - '@changesets/types': 6.1.0 - '@manypkg/get-packages': 1.1.3 - - '@changesets/get-version-range-type@0.4.0': {} + '@changesets/errors': 1.0.0 + '@changesets/types': 7.0.0 + '@manypkg/get-packages': 3.1.0 + picomatch: 4.0.5 + tinyexec: 1.3.0 - '@changesets/git@3.0.4': + '@changesets/parse@1.0.0': dependencies: - '@changesets/errors': 0.2.0 - '@manypkg/get-packages': 1.1.3 - is-subdir: 1.2.0 - micromatch: 4.0.8 - spawndamnit: 3.0.1 + '@changesets/types': 7.0.0 + yaml: 2.9.0 - '@changesets/logger@0.1.1': + '@changesets/pre@3.0.0': dependencies: - picocolors: 1.1.1 + '@changesets/errors': 1.0.0 + '@changesets/types': 7.0.0 + '@manypkg/get-packages': 3.1.0 - '@changesets/parse@0.4.3': + '@changesets/read@1.0.0': dependencies: - '@changesets/types': 6.1.0 - js-yaml: 4.3.1 + '@changesets/git': 4.0.0 + '@changesets/parse': 1.0.0 + '@changesets/types': 7.0.0 - '@changesets/pre@2.0.2': + '@changesets/should-skip-package@1.0.0': dependencies: - '@changesets/errors': 0.2.0 - '@changesets/types': 6.1.0 - '@manypkg/get-packages': 1.1.3 - fs-extra: 7.0.1 + '@changesets/types': 7.0.0 - '@changesets/read@0.6.7': - dependencies: - '@changesets/git': 3.0.4 - '@changesets/logger': 0.1.1 - '@changesets/parse': 0.4.3 - '@changesets/types': 6.1.0 - fs-extra: 7.0.1 - p-filter: 2.1.0 - picocolors: 1.1.1 + '@changesets/types@7.0.0': {} - '@changesets/should-skip-package@0.1.2': + '@changesets/write@1.0.1': dependencies: - '@changesets/types': 6.1.0 - '@manypkg/get-packages': 1.1.3 - - '@changesets/types@4.1.0': {} + '@changesets/format': 0.1.2 + '@changesets/types': 7.0.0 + human-id: 4.2.0 - '@changesets/types@6.1.0': {} + '@clack/core@1.4.3': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 - '@changesets/write@0.4.0': + '@clack/prompts@1.7.0': dependencies: - '@changesets/types': 6.1.0 - fs-extra: 7.0.1 - human-id: 4.2.0 - prettier: 2.8.8 + '@clack/core': 1.4.3 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 '@emnapi/core@1.11.1': dependencies: @@ -2706,9 +2479,9 @@ snapshots: ajv-formats: 3.0.1(ajv@8.20.0) fast-uri: 3.1.5 - '@fastify/cors@11.2.0': + '@fastify/cors@11.3.0': dependencies: - fastify-plugin: 5.1.0 + fastify-plugin: 6.0.0 toad-cache: 3.7.4 '@fastify/error@4.2.0': {} @@ -2717,10 +2490,10 @@ snapshots: dependencies: fast-json-stringify: 7.0.1 - '@fastify/formbody@8.0.2': + '@fastify/formbody@9.0.0': dependencies: fast-querystring: 1.1.2 - fastify-plugin: 5.1.0 + fastify-plugin: 6.0.0 '@fastify/forwarded@3.0.2': {} @@ -2738,13 +2511,6 @@ snapshots: hono: 4.13.0 optional: true - '@inquirer/external-editor@1.0.3(@types/node@26.1.2)': - dependencies: - chardet: 2.2.0 - iconv-lite: 0.7.3 - optionalDependencies: - '@types/node': 26.1.2 - '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -2756,21 +2522,20 @@ snapshots: '@lukeed/csprng@1.1.0': {} - '@manypkg/find-root@1.1.0': + '@manypkg/find-root@3.1.0': dependencies: - '@babel/runtime': 7.29.7 - '@types/node': 12.20.55 - find-up: 4.1.0 - fs-extra: 8.1.0 + '@manypkg/tools': 2.1.2 - '@manypkg/get-packages@1.1.3': + '@manypkg/get-packages@3.1.0': dependencies: - '@babel/runtime': 7.29.7 - '@changesets/types': 4.1.0 - '@manypkg/find-root': 1.1.0 - fs-extra: 8.1.0 - globby: 11.1.0 - read-yaml-file: 1.1.0 + '@manypkg/find-root': 3.1.0 + '@manypkg/tools': 2.1.2 + + '@manypkg/tools@2.1.2': + dependencies: + jju: 1.4.0 + tinyglobby: 0.2.17 + yaml: 2.9.0 '@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@4.4.3)': dependencies: @@ -2802,9 +2567,10 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@nestjs/common@12.0.0-alpha.5(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0)': + '@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0)': dependencies: - file-type: 21.3.4(supports-color@7.2.0) + '@standard-schema/spec': 1.1.0 + file-type: 22.0.2(supports-color@7.2.0) iterare: 1.2.1 load-esm: 1.0.3 reflect-metadata: 0.2.2 @@ -2814,10 +2580,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/core@12.0.0-alpha.5(@nestjs/common@12.0.0-alpha.5(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@12.0.0-alpha.5)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/core@12.0.1(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@12.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 12.0.0-alpha.5(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) - '@nuxt/opencollective': 0.4.1 + '@nestjs/common': 12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) fast-safe-stringify: 2.1.1 iterare: 1.2.1 path-to-regexp: 8.4.2 @@ -2826,12 +2591,12 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/platform-express': 12.0.0-alpha.5(@nestjs/common@12.0.0-alpha.5(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.0.0-alpha.5)(supports-color@7.2.0) + '@nestjs/platform-express': 12.0.1(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.0.1)(supports-color@7.2.0) - '@nestjs/platform-express@12.0.0-alpha.5(@nestjs/common@12.0.0-alpha.5(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.0.0-alpha.5)(supports-color@7.2.0)': + '@nestjs/platform-express@12.0.1(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.0.1)(supports-color@7.2.0)': dependencies: - '@nestjs/common': 12.0.0-alpha.5(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) - '@nestjs/core': 12.0.0-alpha.5(@nestjs/common@12.0.0-alpha.5(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@12.0.0-alpha.5)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) + '@nestjs/core': 12.0.1(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@12.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) cors: 2.8.6 express: 5.2.1(supports-color@7.2.0) multer: 2.2.0 @@ -2840,104 +2605,88 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/platform-fastify@12.0.0-alpha.5(@nestjs/common@12.0.0-alpha.5(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.0.0-alpha.5)': + '@nestjs/platform-fastify@12.0.1(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.0.1)': dependencies: - '@fastify/cors': 11.2.0 - '@fastify/formbody': 8.0.2 - '@nestjs/common': 12.0.0-alpha.5(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) - '@nestjs/core': 12.0.0-alpha.5(@nestjs/common@12.0.0-alpha.5(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@12.0.0-alpha.5)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@fastify/cors': 11.3.0 + '@fastify/formbody': 9.0.0 + '@nestjs/common': 12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) + '@nestjs/core': 12.0.1(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@12.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) fast-querystring: 1.1.2 - fastify: 5.8.5 - fastify-plugin: 5.1.0 - find-my-way: 9.6.0 + fastify: 5.12.1 + fastify-plugin: 6.0.0 + find-my-way: 9.9.0 light-my-request: 6.6.0 path-to-regexp: 8.4.2 reusify: 1.1.0 tslib: 2.8.1 - '@nestjs/testing@12.0.0-alpha.5(@nestjs/common@12.0.0-alpha.5(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.0.0-alpha.5)(@nestjs/platform-express@12.0.0-alpha.5)': + '@nestjs/testing@12.0.1(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.0.1)(@nestjs/platform-express@12.0.1)': dependencies: - '@nestjs/common': 12.0.0-alpha.5(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) - '@nestjs/core': 12.0.0-alpha.5(@nestjs/common@12.0.0-alpha.5(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@12.0.0-alpha.5)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) + '@nestjs/core': 12.0.1(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@12.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-express': 12.0.0-alpha.5(@nestjs/common@12.0.0-alpha.5(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.0.0-alpha.5)(supports-color@7.2.0) + '@nestjs/platform-express': 12.0.1(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.0.1)(supports-color@7.2.0) '@noble/hashes@1.8.0': {} - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 - - '@nuxt/opencollective@0.4.1': - dependencies: - consola: 3.4.2 - '@oxc-project/types@0.139.0': {} - '@oxlint/binding-android-arm-eabi@1.77.0': + '@oxlint/binding-android-arm-eabi@1.80.0': optional: true - '@oxlint/binding-android-arm64@1.77.0': + '@oxlint/binding-android-arm64@1.80.0': optional: true - '@oxlint/binding-darwin-arm64@1.77.0': + '@oxlint/binding-darwin-arm64@1.80.0': optional: true - '@oxlint/binding-darwin-x64@1.77.0': + '@oxlint/binding-darwin-x64@1.80.0': optional: true - '@oxlint/binding-freebsd-x64@1.77.0': + '@oxlint/binding-freebsd-x64@1.80.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.77.0': + '@oxlint/binding-linux-arm-gnueabihf@1.80.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.77.0': + '@oxlint/binding-linux-arm-musleabihf@1.80.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.77.0': + '@oxlint/binding-linux-arm64-gnu@1.80.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.77.0': + '@oxlint/binding-linux-arm64-musl@1.80.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.77.0': + '@oxlint/binding-linux-ppc64-gnu@1.80.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.77.0': + '@oxlint/binding-linux-riscv64-gnu@1.80.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.77.0': + '@oxlint/binding-linux-riscv64-musl@1.80.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.77.0': + '@oxlint/binding-linux-s390x-gnu@1.80.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.77.0': + '@oxlint/binding-linux-x64-gnu@1.80.0': optional: true - '@oxlint/binding-linux-x64-musl@1.77.0': + '@oxlint/binding-linux-x64-musl@1.80.0': optional: true - '@oxlint/binding-openharmony-arm64@1.77.0': + '@oxlint/binding-openharmony-arm64@1.80.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.77.0': + '@oxlint/binding-win32-arm64-msvc@1.80.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.77.0': + '@oxlint/binding-win32-ia32-msvc@1.80.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.77.0': + '@oxlint/binding-win32-x64-msvc@1.80.0': optional: true '@paralleldrive/cuid2@2.3.1': @@ -2946,7 +2695,9 @@ snapshots: '@pinojs/redact@0.4.0': {} - '@publint/pack@0.1.6': + '@pnpm/deps.graph-sequencer@1100.0.1': {} + + '@publint/pack@0.1.7': dependencies: tinyexec: 1.3.0 @@ -3001,36 +2752,36 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@smithy/core@3.31.1': + '@smithy/core@3.33.3': dependencies: - '@smithy/types': 4.16.1 + '@smithy/types': 4.17.2 tslib: 2.8.1 '@smithy/credential-provider-imds@4.4.16': dependencies: - '@smithy/core': 3.31.1 - '@smithy/types': 4.16.1 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@smithy/fetch-http-handler@5.6.13': + '@smithy/fetch-http-handler@5.7.2': dependencies: - '@smithy/core': 3.31.1 - '@smithy/types': 4.16.1 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@smithy/node-http-handler@4.9.13': + '@smithy/node-http-handler@4.11.3': dependencies: - '@smithy/core': 3.31.1 - '@smithy/types': 4.16.1 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 tslib: 2.8.1 '@smithy/signature-v4@5.6.12': dependencies: - '@smithy/core': 3.31.1 - '@smithy/types': 4.16.1 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@smithy/types@4.16.1': + '@smithy/types@4.17.2': dependencies: tslib: 2.8.1 @@ -3063,9 +2814,11 @@ snapshots: '@types/methods@1.1.4': {} - '@types/node@12.20.55': {} + '@types/node@26.3.0': + dependencies: + undici-types: 8.3.0 - '@types/node@26.1.2': + '@types/node@26.4.0': dependencies: undici-types: 8.3.0 @@ -3073,7 +2826,7 @@ snapshots: dependencies: '@types/cookiejar': 2.1.5 '@types/methods': 1.1.4 - '@types/node': 26.1.2 + '@types/node': 26.3.0 form-data: 4.0.6 '@types/supertest@7.2.1': @@ -3143,10 +2896,10 @@ snapshots: '@vercel/oidc@3.2.0': {} - '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': + '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.10 + '@vitest/utils': 4.1.11 ast-v8-to-istanbul: 1.0.5 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 @@ -3155,46 +2908,46 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@26.1.2)) + vitest: 4.1.11(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(vite@8.1.5(@types/node@26.4.0)(yaml@2.9.0)) - '@vitest/expect@4.1.10': + '@vitest/expect@4.1.11': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@26.1.2))': + '@vitest/mocker@4.1.11(vite@8.1.5(@types/node@26.4.0)(yaml@2.9.0))': dependencies: - '@vitest/spy': 4.1.10 + '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.5(@types/node@26.1.2) + vite: 8.1.5(@types/node@26.4.0)(yaml@2.9.0) - '@vitest/pretty-format@4.1.10': + '@vitest/pretty-format@4.1.11': dependencies: tinyrainbow: 3.1.1 - '@vitest/runner@4.1.10': + '@vitest/runner@4.1.11': dependencies: - '@vitest/utils': 4.1.10 + '@vitest/utils': 4.1.11 pathe: 2.0.3 - '@vitest/snapshot@4.1.10': + '@vitest/snapshot@4.1.11': dependencies: - '@vitest/pretty-format': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.10': {} + '@vitest/spy@4.1.11': {} - '@vitest/utils@4.1.10': + '@vitest/utils@4.1.11': dependencies: - '@vitest/pretty-format': 4.1.10 + '@vitest/pretty-format': 4.1.11 convert-source-map: 2.0.0 tinyrainbow: 3.1.1 @@ -3207,11 +2960,11 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - ai@7.0.64(zod@4.4.3): + ai@7.0.83(zod@4.4.3): dependencies: - '@ai-sdk/gateway': 4.0.51(zod@4.4.3) - '@ai-sdk/provider': 4.0.7 - '@ai-sdk/provider-utils': 5.0.27(zod@4.4.3) + '@ai-sdk/gateway': 4.0.67(zod@4.4.3) + '@ai-sdk/provider': 4.0.8 + '@ai-sdk/provider-utils': 5.0.32(zod@4.4.3) zod: 4.4.3 ajv-formats@3.0.1(ajv@8.20.0): @@ -3225,20 +2978,8 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - ansi-colors@4.1.3: {} - - ansi-regex@5.0.1: {} - append-field@1.0.0: {} - argparse@1.0.10: - dependencies: - sprintf-js: 1.0.3 - - argparse@2.0.1: {} - - array-union@2.1.0: {} - asap@2.0.6: {} assertion-error@2.0.1: {} @@ -3262,10 +3003,6 @@ snapshots: base64-js@1.5.1: {} - better-path-resolve@1.0.0: - dependencies: - is-windows: 1.0.2 - body-parser@2.3.0(supports-color@7.2.0): dependencies: bytes: 3.1.2 @@ -3282,10 +3019,6 @@ snapshots: bowser@2.14.1: {} - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - buffer-from@1.1.2: {} buffer@5.6.0: @@ -3299,6 +3032,8 @@ snapshots: bytes@3.1.2: {} + cac@7.0.0: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -3311,8 +3046,6 @@ snapshots: chai@6.2.2: {} - chardet@2.2.0: {} - combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -3328,8 +3061,6 @@ snapshots: readable-stream: 3.6.2 typedarray: 0.0.6 - consola@3.4.2: {} - content-disposition@1.1.0: {} content-type@1.0.5: {} @@ -3356,6 +3087,7 @@ snapshots: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 + optional: true debug@4.4.3(supports-color@7.2.0): dependencies: @@ -3369,8 +3101,6 @@ snapshots: dequal@2.0.3: {} - detect-indent@6.1.0: {} - detect-libc@2.1.2: {} dezalgo@1.0.4: @@ -3378,10 +3108,6 @@ snapshots: asap: 2.0.6 wrappy: 1.0.2 - dir-glob@3.0.1: - dependencies: - path-type: 4.0.0 - dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -3392,11 +3118,6 @@ snapshots: encodeurl@2.0.0: {} - enquirer@2.4.1: - dependencies: - ansi-colors: 4.1.3 - strip-ansi: 6.0.1 - es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -3416,8 +3137,6 @@ snapshots: escape-html@1.0.3: {} - esprima@4.0.1: {} - estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -3477,29 +3196,10 @@ snapshots: transitivePeerDependencies: - supports-color - extendable-error@0.1.7: {} - fast-decode-uri-component@1.0.1: {} fast-deep-equal@3.1.3: {} - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - - fast-json-stringify@6.4.0: - dependencies: - '@fastify/merge-json-schemas': 0.2.1 - ajv: 8.20.0 - ajv-formats: 3.0.1(ajv@8.20.0) - fast-uri: 3.1.5 - json-schema-ref-resolver: 3.0.0 - rfdc: 1.4.1 - fast-json-stringify@7.0.1: dependencies: '@fastify/merge-json-schemas': 0.2.1 @@ -3515,31 +3215,23 @@ snapshots: fast-safe-stringify@2.1.1: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + fast-uri@3.1.5: {} fast-uri@4.1.2: {} - fastify-plugin@5.1.0: {} - - fastify@5.11.2: + fast-wrap-ansi@0.2.2: dependencies: - '@fastify/ajv-compiler': 4.0.5 - '@fastify/error': 4.2.0 - '@fastify/fast-json-stringify-compiler': 5.1.0 - '@fastify/proxy-addr': 5.1.0 - abstract-logging: 2.0.1 - avvio: 9.3.0 - fast-json-stringify: 7.0.1 - find-my-way: 9.7.0 - light-my-request: 6.6.0 - pino: 10.3.1 - process-warning: 5.1.0 - rfdc: 1.4.1 - secure-json-parse: 4.1.0 - semver: 7.8.5 - toad-cache: 3.7.4 + fast-string-width: 3.0.2 + + fastify-plugin@6.0.0: {} - fastify@5.8.5: + fastify@5.12.1: dependencies: '@fastify/ajv-compiler': 4.0.5 '@fastify/error': 4.2.0 @@ -3547,7 +3239,7 @@ snapshots: '@fastify/proxy-addr': 5.1.0 abstract-logging: 2.0.1 avvio: 9.3.0 - fast-json-stringify: 6.4.0 + fast-json-stringify: 7.0.1 find-my-way: 9.7.0 light-my-request: 6.6.0 pino: 10.3.1 @@ -3565,7 +3257,9 @@ snapshots: optionalDependencies: picomatch: 4.0.5 - file-type@21.3.4(supports-color@7.2.0): + fflate@0.8.3: {} + + file-type@22.0.2(supports-color@7.2.0): dependencies: '@tokenizer/inflate': 0.4.1(supports-color@7.2.0) strtok3: 10.3.5 @@ -3574,31 +3268,30 @@ snapshots: transitivePeerDependencies: - supports-color - files-sdk@2.2.3(@aws-sdk/client-s3@3.1103.0)(@aws-sdk/lib-storage@3.1103.0(@aws-sdk/client-s3@3.1103.0))(@aws-sdk/s3-presigned-post@3.1103.0)(@aws-sdk/s3-request-presigner@3.1103.0)(@nestjs/common@12.0.0-alpha.5(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(ai@7.0.64(zod@4.4.3))(fastify@5.11.2)(hono@4.13.0)(supports-color@7.2.0)(zod@4.4.3): + files-sdk@2.3.0(@aws-sdk/client-s3@3.1120.0)(@aws-sdk/lib-storage@3.1120.0(@aws-sdk/client-s3@3.1120.0))(@aws-sdk/s3-presigned-post@3.1120.0)(@aws-sdk/s3-request-presigner@3.1120.0)(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(ai@7.0.83(zod@4.4.3))(fastify@5.12.1)(hono@4.13.0)(supports-color@7.2.0)(zod@4.4.3): dependencies: aws4fetch: 1.0.20 commander: 15.0.0 + fflate: 0.8.3 + mime: 4.1.0 + p-map: 7.0.6 picomatch: 4.0.5 safe-regex2: 5.1.1 optionalDependencies: - '@aws-sdk/client-s3': 3.1103.0 - '@aws-sdk/lib-storage': 3.1103.0(@aws-sdk/client-s3@3.1103.0) - '@aws-sdk/s3-presigned-post': 3.1103.0 - '@aws-sdk/s3-request-presigner': 3.1103.0 + '@aws-sdk/client-s3': 3.1120.0 + '@aws-sdk/lib-storage': 3.1120.0(@aws-sdk/client-s3@3.1120.0) + '@aws-sdk/s3-presigned-post': 3.1120.0 + '@aws-sdk/s3-request-presigner': 3.1120.0 '@modelcontextprotocol/sdk': 1.30.0(supports-color@7.2.0)(zod@4.4.3) - '@nestjs/common': 12.0.0-alpha.5(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) - ai: 7.0.64(zod@4.4.3) - fastify: 5.11.2 + '@nestjs/common': 12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) + ai: 7.0.83(zod@4.4.3) + fastify: 5.12.1 hono: 4.13.0 zod: 4.4.3 transitivePeerDependencies: - '@cfworker/json-schema' - supports-color - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - finalhandler@2.1.1(supports-color@7.2.0): dependencies: debug: 4.4.3(supports-color@7.2.0) @@ -3610,23 +3303,18 @@ snapshots: transitivePeerDependencies: - supports-color - find-my-way@9.6.0: + find-my-way@9.7.0: dependencies: fast-deep-equal: 3.1.3 fast-querystring: 1.1.2 safe-regex2: 5.1.1 - find-my-way@9.7.0: + find-my-way@9.9.0: dependencies: fast-deep-equal: 3.1.3 fast-querystring: 1.1.2 safe-regex2: 5.1.1 - find-up@4.1.0: - dependencies: - locate-path: 5.0.0 - path-exists: 4.0.0 - form-data@4.0.6: dependencies: asynckit: 0.4.0 @@ -3645,18 +3333,6 @@ snapshots: fresh@2.0.0: {} - fs-extra@7.0.1: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 4.0.0 - universalify: 0.1.2 - - fs-extra@8.1.0: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 4.0.0 - universalify: 0.1.2 - fsevents@2.3.3: optional: true @@ -3680,23 +3356,8 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - - globby@11.1.0: - dependencies: - array-union: 2.1.0 - dir-glob: 3.0.1 - fast-glob: 3.3.3 - ignore: 5.3.2 - merge2: 1.4.1 - slash: 3.0.0 - gopd@1.2.0: {} - graceful-fs@4.2.11: {} - has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -3730,7 +3391,7 @@ snapshots: ieee754@1.2.1: {} - ignore@5.3.2: {} + import-meta-resolve@4.2.0: {} inherits@2.0.4: {} @@ -3741,23 +3402,10 @@ snapshots: ipaddr.js@2.5.0: {} - is-extglob@2.1.1: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-number@7.0.0: {} - is-promise@4.0.0: {} - is-subdir@1.2.0: - dependencies: - better-path-resolve: 1.0.0 - - is-windows@1.0.2: {} - - isexe@2.0.0: {} + isexe@2.0.0: + optional: true istanbul-lib-coverage@3.2.2: {} @@ -3774,20 +3422,13 @@ snapshots: iterare@1.2.1: {} + jju@1.4.0: {} + jose@6.2.8: optional: true js-tokens@10.0.0: {} - js-yaml@3.15.1: - dependencies: - argparse: 1.0.10 - esprima: 4.0.1 - - js-yaml@4.3.1: - dependencies: - argparse: 2.0.1 - json-schema-ref-resolver@3.0.0: dependencies: dequal: 2.0.3 @@ -3799,9 +3440,12 @@ snapshots: json-schema@0.4.0: {} - jsonfile@4.0.0: - optionalDependencies: - graceful-fs: 4.2.11 + jsonc-parser@3.3.1: {} + + launch-editor@2.14.1: + dependencies: + picocolors: 1.1.1 + shell-quote: 1.10.0 light-my-request@6.6.0: dependencies: @@ -3860,12 +3504,6 @@ snapshots: load-esm@1.0.3: {} - locate-path@5.0.0: - dependencies: - p-locate: 4.1.0 - - lodash.startcase@4.4.0: {} - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3888,15 +3526,8 @@ snapshots: merge-descriptors@2.0.0: {} - merge2@1.4.1: {} - methods@1.1.2: {} - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.2 - mime-db@1.52.0: {} mime-db@1.54.0: {} @@ -3911,6 +3542,8 @@ snapshots: mime@2.6.0: {} + mime@4.1.0: {} + mri@1.2.0: {} ms@2.1.3: {} @@ -3942,72 +3575,45 @@ snapshots: dependencies: wrappy: 1.0.2 - outdent@0.5.0: {} - - oxlint@1.77.0: + oxlint@1.80.0: optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.77.0 - '@oxlint/binding-android-arm64': 1.77.0 - '@oxlint/binding-darwin-arm64': 1.77.0 - '@oxlint/binding-darwin-x64': 1.77.0 - '@oxlint/binding-freebsd-x64': 1.77.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.77.0 - '@oxlint/binding-linux-arm-musleabihf': 1.77.0 - '@oxlint/binding-linux-arm64-gnu': 1.77.0 - '@oxlint/binding-linux-arm64-musl': 1.77.0 - '@oxlint/binding-linux-ppc64-gnu': 1.77.0 - '@oxlint/binding-linux-riscv64-gnu': 1.77.0 - '@oxlint/binding-linux-riscv64-musl': 1.77.0 - '@oxlint/binding-linux-s390x-gnu': 1.77.0 - '@oxlint/binding-linux-x64-gnu': 1.77.0 - '@oxlint/binding-linux-x64-musl': 1.77.0 - '@oxlint/binding-openharmony-arm64': 1.77.0 - '@oxlint/binding-win32-arm64-msvc': 1.77.0 - '@oxlint/binding-win32-ia32-msvc': 1.77.0 - '@oxlint/binding-win32-x64-msvc': 1.77.0 - - p-filter@2.1.0: - dependencies: - p-map: 2.1.0 - - p-limit@2.3.0: - dependencies: - p-try: 2.2.0 - - p-locate@4.1.0: - dependencies: - p-limit: 2.3.0 - - p-map@2.1.0: {} - - p-try@2.2.0: {} - - package-manager-detector@0.2.11: - dependencies: - quansync: 0.2.11 + '@oxlint/binding-android-arm-eabi': 1.80.0 + '@oxlint/binding-android-arm64': 1.80.0 + '@oxlint/binding-darwin-arm64': 1.80.0 + '@oxlint/binding-darwin-x64': 1.80.0 + '@oxlint/binding-freebsd-x64': 1.80.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.80.0 + '@oxlint/binding-linux-arm-musleabihf': 1.80.0 + '@oxlint/binding-linux-arm64-gnu': 1.80.0 + '@oxlint/binding-linux-arm64-musl': 1.80.0 + '@oxlint/binding-linux-ppc64-gnu': 1.80.0 + '@oxlint/binding-linux-riscv64-gnu': 1.80.0 + '@oxlint/binding-linux-riscv64-musl': 1.80.0 + '@oxlint/binding-linux-s390x-gnu': 1.80.0 + '@oxlint/binding-linux-x64-gnu': 1.80.0 + '@oxlint/binding-linux-x64-musl': 1.80.0 + '@oxlint/binding-openharmony-arm64': 1.80.0 + '@oxlint/binding-win32-arm64-msvc': 1.80.0 + '@oxlint/binding-win32-ia32-msvc': 1.80.0 + '@oxlint/binding-win32-x64-msvc': 1.80.0 + + p-map@7.0.6: {} package-manager-detector@1.8.0: {} parseurl@1.3.3: {} - path-exists@4.0.0: {} - - path-key@3.1.1: {} + path-key@3.1.1: + optional: true path-to-regexp@8.4.2: {} - path-type@4.0.0: {} - pathe@2.0.3: {} picocolors@1.1.1: {} - picomatch@2.3.2: {} - picomatch@4.0.5: {} - pify@4.0.1: {} - pino-abstract-transport@3.0.0: dependencies: split2: 4.2.0 @@ -4037,8 +3643,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - prettier@2.8.8: {} - prettier@3.9.6: {} process-warning@4.0.1: {} @@ -4050,9 +3654,9 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 - publint@0.3.23: + publint@0.3.24: dependencies: - '@publint/pack': 0.1.6 + '@publint/pack': 0.1.7 package-manager-detector: 1.8.0 picocolors: 1.1.1 sade: 1.8.1 @@ -4062,10 +3666,6 @@ snapshots: es-define-property: 1.0.1 side-channel: 1.1.1 - quansync@0.2.11: {} - - queue-microtask@1.2.3: {} - quick-format-unescaped@4.0.4: {} range-parser@1.3.0: {} @@ -4077,13 +3677,6 @@ snapshots: iconv-lite: 0.7.3 unpipe: 1.0.0 - read-yaml-file@1.1.0: - dependencies: - graceful-fs: 4.2.11 - js-yaml: 3.15.1 - pify: 4.0.1 - strip-bom: 3.0.0 - readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -4098,8 +3691,6 @@ snapshots: require-from-string@2.0.2: {} - resolve-from@5.0.0: {} - ret@0.5.0: {} reusify@1.1.0: {} @@ -4137,10 +3728,6 @@ snapshots: transitivePeerDependencies: - supports-color - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - rxjs@7.8.2: dependencies: tslib: 2.8.1 @@ -4195,8 +3782,12 @@ snapshots: shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 + optional: true + + shebang-regex@3.0.0: + optional: true - shebang-regex@3.0.0: {} + shell-quote@1.10.0: {} side-channel-list@1.0.1: dependencies: @@ -4228,9 +3819,7 @@ snapshots: siginfo@2.0.0: {} - signal-exit@4.1.0: {} - - slash@3.0.0: {} + sisteransi@1.0.5: {} sonic-boom@4.2.1: dependencies: @@ -4238,15 +3827,8 @@ snapshots: source-map-js@1.2.1: {} - spawndamnit@3.0.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - split2@4.2.0: {} - sprintf-js@1.0.3: {} - stackback@0.0.2: {} statuses@2.0.2: {} @@ -4264,12 +3846,6 @@ snapshots: dependencies: safe-buffer: 5.2.1 - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-bom@3.0.0: {} - strtok3@10.3.5: dependencies: '@tokenizer/token': 0.3.0 @@ -4300,8 +3876,6 @@ snapshots: dependencies: has-flag: 4.0.0 - term-size@2.2.1: {} - thread-stream@4.2.0: dependencies: real-require: 1.0.0 @@ -4317,10 +3891,6 @@ snapshots: tinyrainbow@3.1.1: {} - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - toad-cache@3.7.4: {} toidentifier@1.0.1: {} @@ -4379,15 +3949,13 @@ snapshots: undici@7.29.0: {} - universalify@0.1.2: {} - unpipe@1.0.0: {} util-deprecate@1.0.2: {} vary@1.1.2: {} - vite@8.1.5(@types/node@26.1.2): + vite@8.1.5(@types/node@26.4.0)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -4395,18 +3963,19 @@ snapshots: rolldown: 1.1.5 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.1.2 + '@types/node': 26.4.0 fsevents: 2.3.3 + yaml: 2.9.0 - vitest@4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@26.1.2)): + vitest@4.1.11(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(vite@8.1.5(@types/node@26.4.0)(yaml@2.9.0)): dependencies: - '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@26.1.2)) - '@vitest/pretty-format': 4.1.10 - '@vitest/runner': 4.1.10 - '@vitest/snapshot': 4.1.10 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.1.5(@types/node@26.4.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 es-module-lexer: 2.3.1 expect-type: 1.4.0 magic-string: 0.30.21 @@ -4418,17 +3987,18 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.1.5(@types/node@26.1.2) + vite: 8.1.5(@types/node@26.4.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 26.1.2 - '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) + '@types/node': 26.4.0 + '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) transitivePeerDependencies: - msw which@2.0.2: dependencies: isexe: 2.0.0 + optional: true why-is-node-running@2.3.0: dependencies: @@ -4437,6 +4007,8 @@ snapshots: wrappy@1.0.2: {} + yaml@2.9.0: {} + zod-to-json-schema@3.25.2(zod@4.4.3): dependencies: zod: 4.4.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6eda500..8d696dc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,17 +1,11 @@ packages: - '.' -# Nest 12.0.0-alpha.* still declares ^11.0.0 peers on some siblings, and # files-sdk's optional Nest integration has not widened its peer range yet. peerDependencyRules: allowedVersions: '@nestjs/common': '12' - '@nestjs/core': '12' - '@nestjs/platform-express': '12' - '@nestjs/platform-fastify': '12' - -overrides: - multer: '2.2.0' + 'ai': '7' # @nestjs/core's postinstall is only a donation banner; never run it. allowBuilds: @@ -22,4 +16,15 @@ allowBuilds: provenance: true minimumReleaseAgeExclude: - - files-sdk@2.2.3 + - files-sdk@2.2.3 || 2.3.0 + - '@nestjs/common@12.0.1' + - '@nestjs/core@12.0.1' + - '@nestjs/platform-express@12.0.1' + - '@nestjs/platform-fastify@12.0.1' + - '@nestjs/testing@12.0.1' + - '@aws-sdk/client-s3@3.1120.0' + - '@aws-sdk/lib-storage@3.1120.0' + - '@aws-sdk/s3-presigned-post@3.1120.0' + - '@aws-sdk/s3-request-presigner@3.1120.0' + - '@types/node@26.4.0' + - ai@7.0.83 diff --git a/src/ai-sdk/ai-sdk-workspace-tools.spec.ts b/src/ai-sdk/ai-sdk-workspace-tools.spec.ts index 93e56b8..68c08f4 100644 --- a/src/ai-sdk/ai-sdk-workspace-tools.spec.ts +++ b/src/ai-sdk/ai-sdk-workspace-tools.spec.ts @@ -892,6 +892,39 @@ describe('createAiSdkWorkspaceTools', () => { }); }); + it('does not map an applied create conflict as an ordinary collision', async () => { + const fixture = createWorkspaceDouble(['create']); + fixture.writeFile.mockRejectedValue( + new StorageWorkspaceError('private post-commit detail', { + applied: true, + appliedEtag: 'committed-etag', + code: StorageErrorCode.CONFLICT, + operation: 'writeFile', + path: 'existing.txt', + }), + ); + const mapCreateConflict = vi.fn(() => ({ + kind: 'already-exists' as const, + })); + const tools = createAiSdkWorkspaceTools({ + workspace: fixture.workspace, + mapCreateConflict, + }); + + await expect( + executeTool(tools, 'workspace_write_file', { + path: 'existing.txt', + content: 'new', + mode: 'create', + }), + ).rejects.toMatchObject({ + applied: true, + appliedEtag: 'committed-etag', + code: StorageErrorCode.CONFLICT, + }); + expect(mapCreateConflict).not.toHaveBeenCalled(); + }); + it('sanitizes failures thrown by a create-conflict mapper', async () => { const fixture = createWorkspaceDouble(['create']); fixture.writeFile.mockRejectedValue( @@ -944,6 +977,8 @@ describe('createAiSdkWorkspaceTools', () => { message: 'The requested workspace path was not found.', }); expect((notFound as Error & { cause?: unknown }).cause).toBeUndefined(); + expect(notFound).not.toHaveProperty('applied'); + expect(notFound).not.toHaveProperty('appliedEtag'); expect(JSON.stringify(notFound)).not.toContain('secret'); fixture.stat.mockRejectedValueOnce( @@ -959,6 +994,55 @@ describe('createAiSdkWorkspaceTools', () => { expect(String(provider)).not.toContain('provider.invalid'); }); + it('retains only bounded post-commit reconciliation metadata', async () => { + const fixture = createWorkspaceDouble(['create']); + fixture.writeFile.mockRejectedValueOnce( + new StorageWorkspaceError('private post-commit provider detail', { + applied: true, + appliedEtag: 'committed-etag', + code: StorageErrorCode.PROVIDER, + operation: 'writeFile', + path: 'private.txt', + }), + ); + const tools = createAiSdkWorkspaceTools({ workspace: fixture.workspace }); + + const applied = await executeTool(tools, 'workspace_write_file', { + content: 'committed body', + mode: 'create', + path: 'private.txt', + }).catch((error: unknown) => error); + expect(applied).toBeInstanceOf(AiSdkWorkspaceToolError); + expect(applied).toMatchObject({ + applied: true, + appliedEtag: 'committed-etag', + code: StorageErrorCode.PROVIDER, + message: 'The workspace operation failed.', + }); + expect(JSON.stringify(applied)).not.toContain('private'); + + fixture.writeFile.mockRejectedValueOnce( + new StorageWorkspaceError('private malformed ETag detail', { + applied: true, + appliedEtag: 'unsafe\r\nX-Provider-Secret: leaked', + code: StorageErrorCode.PROVIDER, + operation: 'writeFile', + path: 'private.txt', + }), + ); + const malformed = await executeTool(tools, 'workspace_write_file', { + content: 'committed body', + mode: 'create', + path: 'private.txt', + }).catch((error: unknown) => error); + expect(malformed).toMatchObject({ + applied: true, + code: StorageErrorCode.PROVIDER, + }); + expect(malformed).not.toHaveProperty('appliedEtag'); + expect(JSON.stringify(malformed)).not.toContain('X-Provider-Secret'); + }); + it('reports an aborted call without exposing the underlying failure', async () => { const fixture = createWorkspaceDouble(['read']); fixture.stat.mockRejectedValueOnce(new Error('private provider failure')); diff --git a/src/ai-sdk/ai-sdk-workspace-tools.ts b/src/ai-sdk/ai-sdk-workspace-tools.ts index 7a766b6..bee34ea 100644 --- a/src/ai-sdk/ai-sdk-workspace-tools.ts +++ b/src/ai-sdk/ai-sdk-workspace-tools.ts @@ -84,6 +84,13 @@ export interface CreateAiSdkWorkspaceToolsOptions< export type AiSdkWorkspaceToolErrorCode = StorageErrorCodeValue; +export interface AiSdkWorkspaceToolErrorOptions { + /** True when the failed operation may already have committed. */ + readonly applied?: boolean; + /** Canonical ETag of the committed object, when the provider returned one. */ + readonly appliedEtag?: string; +} + const SAFE_ERROR_MESSAGES: Readonly< Record > = { @@ -106,15 +113,31 @@ const SAFE_ERROR_MESSAGES: Readonly< /** * Safe error exposed at the AI tool boundary. It deliberately carries no - * provider error, storage key, mount prefix, store name, or cause. + * provider error, storage key, mount prefix, store name, or cause. Applied + * reconciliation metadata is retained so callers do not blindly retry a + * mutation that may already have committed. */ export class AiSdkWorkspaceToolError extends Error { readonly code: AiSdkWorkspaceToolErrorCode; + declare readonly applied?: true; + declare readonly appliedEtag?: string; - constructor(code: AiSdkWorkspaceToolErrorCode) { + constructor( + code: AiSdkWorkspaceToolErrorCode, + options: AiSdkWorkspaceToolErrorOptions = {}, + ) { super(SAFE_ERROR_MESSAGES[code]); this.name = 'AiSdkWorkspaceToolError'; this.code = code; + if (options.applied === true) { + this.applied = true; + if ( + options.appliedEtag !== undefined && + isCanonicalStorageEtag(options.appliedEtag) + ) { + this.appliedEtag = options.appliedEtag; + } + } } } @@ -286,14 +309,26 @@ function sanitizeToolError( if (error instanceof AiSdkWorkspaceToolError) { return error; } + const reconciliation = + isStorageWorkspaceError(error) || isStorageError(error) + ? { + applied: error.applied, + ...(error.appliedEtag !== undefined && { + appliedEtag: error.appliedEtag, + }), + } + : {}; if (signal?.aborted === true) { - return new AiSdkWorkspaceToolError(StorageErrorCode.ABORTED); + return new AiSdkWorkspaceToolError( + StorageErrorCode.ABORTED, + reconciliation, + ); } if (isStorageWorkspaceError(error)) { - return new AiSdkWorkspaceToolError(error.code); + return new AiSdkWorkspaceToolError(error.code, reconciliation); } if (isStorageError(error)) { - return new AiSdkWorkspaceToolError(error.code); + return new AiSdkWorkspaceToolError(error.code, reconciliation); } return new AiSdkWorkspaceToolError(StorageErrorCode.PROVIDER); } @@ -326,6 +361,7 @@ async function executeCreateAware< if ( input.mode === 'create' && safeError.code === StorageErrorCode.CONFLICT && + safeError.applied !== true && mapCreateConflict !== undefined ) { try { diff --git a/src/ai-sdk/index.ts b/src/ai-sdk/index.ts index b8400fc..0ddca7e 100644 --- a/src/ai-sdk/index.ts +++ b/src/ai-sdk/index.ts @@ -14,6 +14,7 @@ export { type AiSdkWorkspacePageResult, type AiSdkWorkspaceTextFileResult, type AiSdkWorkspaceToolErrorCode, + type AiSdkWorkspaceToolErrorOptions, type AiSdkWorkspaceToolName, type CreateAiSdkWorkspaceToolsOptions, } from './ai-sdk-workspace-tools.js'; diff --git a/src/files-sdk/files-sdk.driver.spec.ts b/src/files-sdk/files-sdk.driver.spec.ts index 2802298..51ddad6 100644 --- a/src/files-sdk/files-sdk.driver.spec.ts +++ b/src/files-sdk/files-sdk.driver.spec.ts @@ -1,9 +1,20 @@ import { Readable } from 'node:stream'; import { inspect } from 'node:util'; -import { createStoredFile, handlers, type FilesHooks } from 'files-sdk'; +import { + createStoredFile, + handlers, + type AdapterDownloadOptions, + type AdapterUploadOptions, + type Body, + type CopyCondition, + type FilesActionEvent, + type FilesHooks, + type OperationOptions, +} from 'files-sdk'; import { memory } from 'files-sdk/memory'; +import { StorageClient } from '../storage.client.js'; import { StorageError, StorageErrorCode, @@ -12,6 +23,7 @@ import { } from '../storage.error.js'; import { createFilesSdkDriver, + mapFilesSdkError, markFilesSdkS3AdapterProvenance, markFilesSdkS3AdapterUndecorated, } from './files-sdk.driver.js'; @@ -42,6 +54,76 @@ function s3BackedMemoryAdapter(name = 's3') { }); } +function conditionalMemoryAdapter() { + const base = memory(); + const create = vi.fn( + async (key: string, _body: Body, options?: AdapterUploadOptions) => ({ + contentType: options?.contentType ?? 'application/octet-stream', + etag: 'created-etag', + key, + size: 10, + }), + ); + const replace = vi.fn( + async ( + key: string, + _body: Body, + _etag: string, + options?: AdapterUploadOptions, + ) => ({ + contentType: options?.contentType ?? 'application/octet-stream', + etag: 'replaced-etag', + key, + size: 11, + }), + ); + const exactRead = vi.fn( + async (key: string, etag: string, _options?: AdapterDownloadOptions) => + createStoredFile( + { + etag, + key, + size: 4, + type: 'text/plain', + }, + { data: new TextEncoder().encode('body'), kind: 'buffer' }, + ), + ); + const deleteConditional = vi.fn( + async (_key: string, _etag: string, _options?: OperationOptions) => {}, + ); + const copy = vi.fn( + async ( + _sourceKey: string, + _destinationKey: string, + _condition: CopyCondition, + _options?: OperationOptions, + ) => {}, + ); + return { + adapter: Object.assign(base, { + conditional: Object.freeze({ + copy: Object.freeze({ + atomicSourceDestination: true as const, + destinationCreate: true, + destinationReplace: true, + run: copy, + sourceEtag: true as const, + }), + create, + delete: deleteConditional, + exactRead, + replace, + }), + }), + copy, + create, + deleteConditional, + exactRead, + replace, + }; +} + describe('FilesSdkStorageDriver', () => { it('maps a not-found FilesError from another package copy without retaining provider details', async () => { const providerMessage = 'missing object request-id=secret-not-found'; @@ -220,6 +302,28 @@ describe('FilesSdkStorageDriver', () => { expect(serialized).not.toContain('secret flagged nested request id'); }); + it('preserves applied state while dropping a non-canonical Files applied ETag', () => { + const mapped = mapFilesSdkError( + Object.assign(new Error('private post-commit detail'), { + aborted: false, + applied: true, + appliedEtag: 'unsafe\\etag', + code: 'Provider', + name: 'FilesError', + permanent: true, + timedOut: false, + }), + ); + + expect(mapped).toMatchObject({ + applied: true, + code: StorageErrorCode.PROVIDER, + message: 'Storage provider operation failed.', + permanent: true, + }); + expect(mapped.appliedEtag).toBeUndefined(); + }); + it('preserves caller error classification without exposing its message or identity', async () => { const expected = new StorageError('stream limit reached', { code: StorageErrorCode.LIMIT_EXCEEDED, @@ -371,7 +475,95 @@ describe('FilesSdkStorageDriver', () => { }); }); - it('rejects a conditional adapter result from the wrong physical key', async () => { + it('rejects invalid conditional upload discriminants before direct adapter dispatch', async () => { + const uploadConditional = vi.fn(); + const adapter = Object.assign(memory(), { + conditionalCreate: { resultEtag: true }, + conditionalReplace: { resultEtag: true }, + uploadConditional, + }); + const driver = createFilesSdkDriver({ adapter }); + + for (const condition of [undefined, null, {}, { type: 'upsert' }]) { + await expect( + driver.uploadConditional('requested.txt', 'body', { + condition, + } as never), + ).rejects.toMatchObject({ + code: StorageErrorCode.INVALID_ARGUMENT, + permanent: true, + }); + } + expect(uploadConditional).not.toHaveBeenCalled(); + }); + + it('rejects an empty conditional read before direct adapter dispatch', async () => { + const downloadConditional = vi.fn(); + const adapter = Object.assign(memory(), { + conditionalRead: { etag: true, version: true }, + downloadConditional, + }); + const driver = createFilesSdkDriver({ adapter }); + + await expect( + driver.downloadConditional('requested.txt', { + condition: {}, + } as never), + ).rejects.toMatchObject({ + code: StorageErrorCode.INVALID_ARGUMENT, + permanent: true, + }); + expect(downloadConditional).not.toHaveBeenCalled(); + }); + + it('rejects a missing conditional-delete predicate before direct adapter dispatch', async () => { + const deleteConditional = vi.fn(); + const adapter = Object.assign(memory(), { + conditionalDelete: { etag: true }, + deleteConditional, + }); + const driver = createFilesSdkDriver({ adapter }); + + for (const condition of [undefined, null, {}]) { + await expect( + driver.deleteConditional('requested.txt', { condition } as never), + ).rejects.toMatchObject({ + code: StorageErrorCode.INVALID_ARGUMENT, + permanent: true, + }); + } + expect(deleteConditional).not.toHaveBeenCalled(); + }); + + it('rejects a direct promotion without a source or destination predicate', async () => { + const promote = vi.fn(); + const adapter = Object.assign(memory(), { + conditionalCopyDestination: { + atomicWithSource: true, + create: true, + replace: true, + }, + conditionalCopySource: { etag: true, version: true }, + promote, + }); + const driver = createFilesSdkDriver({ adapter }); + + await expect( + driver.promote('source.txt', 'destination.txt', {}), + ).rejects.toMatchObject({ + code: StorageErrorCode.INVALID_ARGUMENT, + permanent: true, + }); + await expect( + driver.promote('source.txt', 'destination.txt', undefined as never), + ).rejects.toMatchObject({ + code: StorageErrorCode.INVALID_ARGUMENT, + permanent: true, + }); + expect(promote).not.toHaveBeenCalled(); + }); + + it('marks a wrong-key direct conditional upload result as applied', async () => { const adapter = Object.assign(memory(), { conditionalCreate: { resultEtag: true }, conditionalDelete: { etag: true }, @@ -379,7 +571,7 @@ describe('FilesSdkStorageDriver', () => { deleteConditional: vi.fn(async () => undefined), uploadConditional: vi.fn(async () => ({ contentType: 'text/plain', - etag: 'etag', + etag: '"committed-etag"', key: 'scope/other.txt', size: 4, })), @@ -391,7 +583,10 @@ describe('FilesSdkStorageDriver', () => { condition: { type: 'create' }, }), ).rejects.toMatchObject({ + applied: true, + appliedEtag: 'committed-etag', code: StorageErrorCode.PROVIDER, + permanent: true, }); expect(adapter.uploadConditional).toHaveBeenCalledWith( 'scope/requested.txt', @@ -400,6 +595,33 @@ describe('FilesSdkStorageDriver', () => { ); }); + it('marks an invalid direct conditional upload result ETag as applied', async () => { + const uploadConditional = vi.fn(async (key: string) => ({ + contentType: 'text/plain', + etag: '"stale","current"', + key, + size: 4, + })); + const adapter = Object.assign(memory(), { + conditionalCreate: { resultEtag: true }, + uploadConditional, + }); + const driver = createFilesSdkDriver({ adapter }); + + const error = await rejectedStorageError(() => + driver.uploadConditional('requested.txt', 'body', { + condition: { type: 'create' }, + }), + ); + expect(error).toMatchObject({ + applied: true, + code: StorageErrorCode.PROVIDER, + permanent: true, + }); + expect(error.appliedEtag).toBeUndefined(); + expect(uploadConditional).toHaveBeenCalledOnce(); + }); + it('validates an unprefixed physical-key budget against the exact adapter key before dispatch', async () => { const adapter = Object.assign(memory(), { physicalKey: { maxBytes: 1_024 }, @@ -658,51 +880,143 @@ describe('FilesSdkStorageDriver', () => { expect(list).not.toHaveBeenCalled(); }); - it('keeps ordinary uploads in the Files plugin pipeline and fails conditional uploads closed', async () => { - const adapter = Object.assign(memory(), { - conditionalCreate: { resultEtag: true }, - uploadConditional: vi.fn(async (key: string) => ({ - contentType: 'text/plain', - etag: 'conditional-etag', - key, - size: 10, - })), - }); - const upload = vi.spyOn(adapter, 'upload'); - const transform = vi.fn(); + it('routes native conditionals through Files plugins, hooks, and receipts', async () => { + const native = conditionalMemoryAdapter(); + const seen: string[] = []; + const actions: FilesActionEvent[] = []; const driver = createFilesSdkDriver({ - adapter, + adapter: native.adapter, + hooks: { onAction: (event) => actions.push(event) }, plugins: [ { - name: 'body-transform', + name: 'conditional-observer', wrap: handlers({ upload: (operation, next) => { - transform(operation.body); + seen.push(`${operation.kind}:${operation.mode}`); return next({ ...operation, body: 'ciphertext' }); }, + copy: (operation, next) => { + seen.push(`${operation.kind}:${operation.mode}`); + return next(operation); + }, + delete: (operation, next) => { + seen.push(`${operation.kind}:${operation.mode}`); + return next(operation); + }, + download: (operation, next) => { + seen.push(`${operation.kind}:${operation.mode}`); + return next(operation); + }, }), }, ], + receipts: true, }); - expect(driver.capabilities.conditionalCreate).toBeUndefined(); - await expect(driver.upload('ordinary.txt', 'plaintext')).resolves.toEqual( - expect.objectContaining({ key: 'ordinary.txt' }), + expect(driver.capabilities).toMatchObject({ + conditionalCopyDestination: { + atomicWithSource: true, + create: true, + replace: true, + requiresSourcePredicate: true, + }, + conditionalCopySource: { + etag: true, + requiresDestinationPredicate: true, + version: false, + }, + conditionalCreate: { resultEtag: true }, + conditionalDelete: { etag: true }, + conditionalRead: { etag: true, version: false }, + conditionalReplace: { resultEtag: true }, + }); + const created = await driver.uploadConditional( + 'conditional.txt', + 'plaintext', + { + contentType: 'text/plain', + condition: { type: 'create' }, + }, ); - expect(transform).toHaveBeenCalledOnce(); - expect(transform).toHaveBeenCalledWith('plaintext'); - expect(upload.mock.calls[0]?.[1]).toBe('ciphertext'); + await driver.downloadConditional('conditional.txt', { + condition: { etag: created.etag as string }, + }); + await driver.promote('conditional.txt', 'copy.txt', { + destination: { type: 'create' }, + sourceEtag: created.etag as string, + }); + await driver.deleteConditional('conditional.txt', { + condition: { etag: created.etag as string }, + }); - await expect( - driver.uploadConditional('conditional.txt', 'plaintext', { + expect(native.create).toHaveBeenCalledWith( + 'conditional.txt', + 'ciphertext', + expect.objectContaining({ contentType: 'text/plain' }), + ); + expect(native.exactRead).toHaveBeenCalledOnce(); + expect(native.copy).toHaveBeenCalledOnce(); + expect(native.deleteConditional).toHaveBeenCalledOnce(); + expect(seen).toEqual([ + 'upload:create', + 'download:exact', + 'copy:conditional', + 'delete:match', + ]); + expect( + actions.map(({ condition, receipt, type }) => ({ + condition, + receipt: receipt?.condition, + type, + })), + ).toEqual([ + { condition: 'create', receipt: 'create', type: 'upload' }, + { condition: 'exact-read', receipt: undefined, type: 'download' }, + { + condition: 'conditional-copy', + receipt: 'conditional-copy', + type: 'copy', + }, + { + condition: 'match-delete', + receipt: 'match-delete', + type: 'delete', + }, + ]); + }); + + it('preserves applied reconciliation metadata after a post-commit plugin failure', async () => { + const native = conditionalMemoryAdapter(); + const driver = createFilesSdkDriver({ + adapter: native.adapter, + plugins: [ + { + name: 'failing-observer', + wrap: handlers({ + upload: async (operation, next) => { + await next(operation); + throw new Error('private observer failure'); + }, + }), + }, + ], + }); + + const error = await rejectedStorageError(() => + driver.uploadConditional('applied.txt', 'body', { condition: { type: 'create' }, }), - ).rejects.toMatchObject({ - code: StorageErrorCode.NOT_SUPPORTED, - permanent: true, + ); + + expect(error).toMatchObject({ + applied: true, + appliedEtag: 'created-etag', + cause: undefined, + code: StorageErrorCode.PROVIDER, + message: 'Storage provider operation failed.', }); - expect(transform).toHaveBeenCalledOnce(); - expect(adapter.uploadConditional).not.toHaveBeenCalled(); + expect(native.create).toHaveBeenCalledOnce(); + expect(logShape(error)).not.toContain('private observer failure'); }); it.each([ @@ -733,9 +1047,10 @@ describe('FilesSdkStorageDriver', () => { policyName: 'receipt options', }, ])( - 'hides and blocks every conditional operation with $policyName', + 'keeps native conditionals available with $policyName and hides only direct fallbacks', async ({ filesPolicy }) => { - const adapter = Object.assign(memory(), { + const native = conditionalMemoryAdapter(); + const legacy = { conditionalCopyDestination: { atomicWithSource: true, create: true, @@ -751,38 +1066,70 @@ describe('FilesSdkStorageDriver', () => { downloadConditional: vi.fn(), promote: vi.fn(), uploadConditional: vi.fn(), - }); + }; + const adapter = Object.assign(native.adapter, legacy); const driver = createFilesSdkDriver({ adapter, ...filesPolicy }); - expect(driver.capabilities.conditionalCopyDestination).toBeUndefined(); - expect(driver.capabilities.conditionalCopySource).toBeUndefined(); - expect(driver.capabilities.conditionalCreate).toBeUndefined(); - expect(driver.capabilities.conditionalDelete).toBeUndefined(); + expect(driver.capabilities.conditionalCopyDestination).toEqual({ + atomicWithSource: true, + create: true, + replace: true, + requiresSourcePredicate: true, + }); + expect(driver.capabilities.conditionalCopySource).toEqual({ + etag: true, + requiresDestinationPredicate: true, + version: false, + }); + expect(driver.capabilities.conditionalCreate).toEqual({ + resultEtag: true, + }); + expect(driver.capabilities.conditionalDelete).toEqual({ etag: true }); expect( driver.capabilities.conditionalMultipartCompletion, ).toBeUndefined(); - expect(driver.capabilities.conditionalRead).toBeUndefined(); - expect(driver.capabilities.conditionalReplace).toBeUndefined(); + expect(driver.capabilities.conditionalRead).toEqual({ + etag: true, + version: false, + }); + expect(driver.capabilities.conditionalReplace).toEqual({ + resultEtag: true, + }); await expect( driver.uploadConditional('create.txt', 'plaintext', { condition: { type: 'create' }, }), - ).rejects.toMatchObject({ - code: StorageErrorCode.NOT_SUPPORTED, - permanent: true, - }); + ).resolves.toMatchObject({ etag: 'created-etag', key: 'create.txt' }); await expect( driver.downloadConditional('read.txt', { condition: { etag: 'current-etag' }, }), + ).resolves.toMatchObject({ etag: 'current-etag', key: 'read.txt' }); + await expect( + driver.deleteConditional('delete.txt', { + condition: { etag: 'current-etag' }, + }), + ).resolves.toBeUndefined(); + await expect( + driver.promote('source.txt', 'destination.txt', { + destination: { type: 'create' }, + sourceEtag: 'source-etag', + }), + ).resolves.toBeUndefined(); + + await expect( + driver.uploadConditional('multipart.txt', 'plaintext', { + condition: { type: 'create' }, + multipart: true, + }), ).rejects.toMatchObject({ code: StorageErrorCode.NOT_SUPPORTED, permanent: true, }); await expect( - driver.deleteConditional('delete.txt', { - condition: { etag: 'current-etag' }, + driver.downloadConditional('versioned.txt', { + condition: { version: 'version-1' }, }), ).rejects.toMatchObject({ code: StorageErrorCode.NOT_SUPPORTED, @@ -790,7 +1137,6 @@ describe('FilesSdkStorageDriver', () => { }); await expect( driver.promote('source.txt', 'destination.txt', { - destination: { type: 'create' }, sourceEtag: 'source-etag', }), ).rejects.toMatchObject({ @@ -798,10 +1144,14 @@ describe('FilesSdkStorageDriver', () => { permanent: true, }); - expect(adapter.uploadConditional).not.toHaveBeenCalled(); - expect(adapter.downloadConditional).not.toHaveBeenCalled(); - expect(adapter.deleteConditional).not.toHaveBeenCalled(); - expect(adapter.promote).not.toHaveBeenCalled(); + expect(native.create).toHaveBeenCalledOnce(); + expect(native.exactRead).toHaveBeenCalledOnce(); + expect(native.deleteConditional).toHaveBeenCalledOnce(); + expect(native.copy).toHaveBeenCalledOnce(); + expect(legacy.uploadConditional).not.toHaveBeenCalled(); + expect(legacy.downloadConditional).not.toHaveBeenCalled(); + expect(legacy.deleteConditional).not.toHaveBeenCalled(); + expect(legacy.promote).not.toHaveBeenCalled(); }, ); @@ -833,6 +1183,109 @@ describe('FilesSdkStorageDriver', () => { expect(adapter.uploadConditional).toHaveBeenCalledOnce(); }); + it('does not widen a direct conditional upload result-ETag claim', async () => { + const adapter = Object.assign(memory(), { + conditionalCreate: { resultEtag: false }, + uploadConditional: vi.fn(async (key: string) => ({ + contentType: 'text/plain', + key, + size: 4, + })), + }); + const driver = createFilesSdkDriver({ adapter }); + + expect(driver.capabilities.conditionalCreate).toEqual({ + resultEtag: false, + }); + await expect( + driver.uploadConditional('conditional.txt', 'body', { + condition: { type: 'create' }, + }), + ).resolves.toEqual({ + contentType: 'text/plain', + key: 'conditional.txt', + size: 4, + }); + }); + + it('merges native and direct copy predicates only when their dependency matrix is coherent', async () => { + const native = conditionalMemoryAdapter(); + const promote = vi.fn(async () => undefined); + const adapter = Object.assign(native.adapter, { + conditionalCopyDestination: { + atomicWithSource: true, + create: true, + replace: true, + requiresSourcePredicate: true, + }, + conditionalCopySource: { + etag: false, + requiresDestinationPredicate: true, + version: true, + }, + promote, + }); + const client = new StorageClient( + 'coherent-copy', + createFilesSdkDriver({ adapter }), + ); + + expect(client.capabilities.conditionalCopySource).toEqual({ + etag: true, + requiresDestinationPredicate: true, + version: true, + }); + expect(client.capabilities.conditionalCopyDestination).toEqual({ + atomicWithSource: true, + create: true, + replace: true, + requiresSourcePredicate: true, + }); + + await client.promote('source.txt', 'etag-copy.txt', { + destination: { type: 'create' }, + sourceEtag: 'source-etag', + }); + await client.promote('source.txt', 'version-copy.txt', { + destination: { type: 'create' }, + sourceVersion: 'version-1', + }); + expect(native.copy).toHaveBeenCalledOnce(); + expect(promote).toHaveBeenCalledOnce(); + }); + + it('does not splice a one-sided direct copy predicate into the native paired surface', () => { + const native = conditionalMemoryAdapter(); + const promote = vi.fn(async () => undefined); + const adapter = Object.assign(native.adapter, { + conditionalCopySource: { + etag: false, + version: true, + }, + promote, + }); + const client = new StorageClient( + 'incoherent-copy', + createFilesSdkDriver({ adapter }), + ); + + expect(client.capabilities.conditionalCopySource).toEqual({ + etag: false, + version: true, + }); + expect(client.capabilities.conditionalCopyDestination).toBeUndefined(); + expect(() => + client.promote('source.txt', 'destination.txt', { + destination: { type: 'create' }, + sourceVersion: 'version-1', + }), + ).toThrow( + expect.objectContaining({ code: StorageErrorCode.NOT_SUPPORTED }), + ); + expect(promote).not.toHaveBeenCalled(); + expect(native.copy).not.toHaveBeenCalled(); + }); + it('snapshots an inactive hooks object before deciding conditional compatibility', async () => { const adapter = Object.assign(memory(), { conditionalCreate: { resultEtag: true }, diff --git a/src/files-sdk/files-sdk.driver.ts b/src/files-sdk/files-sdk.driver.ts index 61a2c65..3829cc1 100644 --- a/src/files-sdk/files-sdk.driver.ts +++ b/src/files-sdk/files-sdk.driver.ts @@ -6,6 +6,7 @@ import { handlers, type Adapter, type Body, + type ConditionalUploadOptions, type DownloadOptions, type FilesOptions, type FilesPlugin, @@ -65,8 +66,9 @@ export type FilesSdkDriverOptions = FilesOptions; /** - * Interim compatibility gate until Files SDK exposes native conditional/CAS - * verbs through the same operation pipeline as its generic data plane. + * Detects caller policy that must not be bypassed by NestM-only conditional + * fallbacks that Files SDK cannot represent (versions, multipart completion, + * or a copy with only one side conditioned). */ function hasCallerFilesOperationPolicy( options: FilesSdkDriverOptions, @@ -103,6 +105,7 @@ const FILES_SDK_S3_RESERVED_EXTENSION_KEYS = [ const FILES_SDK_S3_AUTHORITY_KEYS = [ ...FILES_SDK_S3_RESERVED_EXTENSION_KEYS, 'bucket', + 'conditional', 'copy', 'delete', 'deleteMany', @@ -446,10 +449,14 @@ function conditionalCopyAdapterOf( (source === undefined && destination === undefined) || (source !== undefined && (typeof source.etag !== 'boolean' || - typeof source.version !== 'boolean')) || + typeof source.version !== 'boolean' || + (source.requiresDestinationPredicate !== undefined && + typeof source.requiresDestinationPredicate !== 'boolean'))) || (destination !== undefined && (typeof destination.create !== 'boolean' || typeof destination.replace !== 'boolean' || + (destination.requiresSourcePredicate !== undefined && + typeof destination.requiresSourcePredicate !== 'boolean') || typeof destination.atomicWithSource !== 'boolean')) || typeof candidate.promote !== 'function' ) { @@ -458,6 +465,154 @@ function conditionalCopyAdapterOf( return candidate as Adapter & FilesSdkConditionalCopyAdapter; } +interface FilesConditionalCopyCapability { + readonly atomicSourceDestination: boolean; + readonly destinationCreate: boolean; + readonly destinationReplace: boolean; + readonly sourceEtag: boolean; +} + +interface ConditionalCopyCapabilities { + readonly source?: StorageConditionalCopySourceCapability; + readonly destination?: StorageConditionalCopyDestinationCapability; +} + +function conditionalCopyCapabilities( + direct: FilesSdkConditionalCopyAdapter | undefined, + pipeline: FilesConditionalCopyCapability, +): ConditionalCopyCapabilities { + const directSource = + direct?.conditionalCopySource !== undefined && + (direct.conditionalCopySource.etag || direct.conditionalCopySource.version) + ? direct.conditionalCopySource + : undefined; + const directDestination = + direct?.conditionalCopyDestination !== undefined && + (direct.conditionalCopyDestination.create || + direct.conditionalCopyDestination.replace) + ? direct.conditionalCopyDestination + : undefined; + const pipelineSupported = + pipeline.sourceEtag && + pipeline.atomicSourceDestination && + (pipeline.destinationCreate || pipeline.destinationReplace); + const pipelineCapabilities: ConditionalCopyCapabilities = pipelineSupported + ? { + source: { + etag: true, + requiresDestinationPredicate: true, + version: false, + }, + destination: { + atomicWithSource: true, + create: pipeline.destinationCreate, + replace: pipeline.destinationReplace, + requiresSourcePredicate: true, + }, + } + : {}; + + if (directSource === undefined && directDestination === undefined) { + return pipelineCapabilities; + } + if (!pipelineSupported) { + return { + ...(directSource !== undefined && { source: { ...directSource } }), + ...(directDestination !== undefined && { + destination: { ...directDestination }, + }), + }; + } + // Never splice unrelated one-sided direct declarations onto the paired + // Files surface. Without both direct halves there is no coherent route for + // the cross-product the public capability model would otherwise imply. + if (directSource === undefined || directDestination === undefined) { + return { + ...(directSource !== undefined && { source: { ...directSource } }), + ...(directDestination !== undefined && { + destination: { ...directDestination }, + }), + }; + } + + const sourcePredicates = [ + ...(directSource.etag || pipeline.sourceEtag ? (['etag'] as const) : []), + ...(directSource.version ? (['version'] as const) : []), + ]; + const destinationPredicates = [ + ...(directDestination.create || pipeline.destinationCreate + ? (['create'] as const) + : []), + ...(directDestination.replace || pipeline.destinationReplace + ? (['replace'] as const) + : []), + ]; + const supportsDirectSourceAlone = ( + predicate: (typeof sourcePredicates)[number], + ): boolean => + directSource[predicate] && + directSource.requiresDestinationPredicate !== true; + const supportsDirectDestinationAlone = ( + predicate: (typeof destinationPredicates)[number], + ): boolean => + directDestination[predicate] && + directDestination.requiresSourcePredicate !== true; + const sourceAlone = sourcePredicates.map(supportsDirectSourceAlone); + const destinationAlone = destinationPredicates.map( + supportsDirectDestinationAlone, + ); + // One shared dependency flag cannot express a union where only some source + // or destination predicate kinds require their counterpart. + if ( + (sourceAlone.some(Boolean) && !sourceAlone.every(Boolean)) || + (destinationAlone.some(Boolean) && !destinationAlone.every(Boolean)) + ) { + return { + source: { ...directSource }, + destination: { ...directDestination }, + }; + } + + const everyPairIsAtomic = sourcePredicates.every((source) => + destinationPredicates.every((destination) => { + const throughPipeline = + source === 'etag' && + (destination === 'create' + ? pipeline.destinationCreate + : pipeline.destinationReplace); + const throughDirect = + directSource[source] && + directDestination[destination] && + directDestination.atomicWithSource; + return throughPipeline || throughDirect; + }), + ); + if (!everyPairIsAtomic) { + return { + source: { ...directSource }, + destination: { ...directDestination }, + }; + } + + return { + source: { + etag: sourcePredicates.includes('etag'), + ...(sourceAlone.every((supported) => !supported) && { + requiresDestinationPredicate: true, + }), + version: sourcePredicates.includes('version'), + }, + destination: { + atomicWithSource: true, + create: destinationPredicates.includes('create'), + replace: destinationPredicates.includes('replace'), + ...(destinationAlone.every((supported) => !supported) && { + requiresSourcePredicate: true, + }), + }, + }; +} + function conditionalUploadAdapterOf( adapter: Adapter, ): FilesSdkConditionalUploadAdapter | undefined { @@ -601,6 +756,8 @@ interface FilesErrorLike { readonly aborted: boolean; readonly timedOut: boolean; readonly permanent: boolean; + readonly applied?: boolean; + readonly appliedEtag?: string; readonly cause?: unknown; } @@ -633,7 +790,9 @@ function isFilesErrorLike(error: unknown): error is FilesErrorLike { 'timedOut' in error && typeof error.timedOut === 'boolean' && 'permanent' in error && - typeof error.permanent === 'boolean' + typeof error.permanent === 'boolean' && + (!('applied' in error) || typeof error.applied === 'boolean') && + (!('appliedEtag' in error) || typeof error.appliedEtag === 'string') ); } catch { return false; @@ -649,6 +808,7 @@ function unwrapFilesError(error: FilesErrorLike): FilesErrorLike { !current.aborted && !current.timedOut && !current.permanent && + current.applied !== true && isFilesErrorLike(current.cause) && current.message === current.cause.message && !seen.has(current.cause) @@ -683,12 +843,53 @@ const PUBLIC_PROVIDER_ERROR_MESSAGES: Readonly< function sanitizedStorageError(error: StorageError): StorageError { return new StorageError(PUBLIC_PROVIDER_ERROR_MESSAGES[error.code], { aborted: error.aborted, + applied: error.applied, + ...(isCanonicalStorageEtag(error.appliedEtag) && { + appliedEtag: error.appliedEtag, + }), code: error.code, permanent: error.permanent, timedOut: error.timedOut, }); } +function filesErrorCodeOfStorage(error: StorageError): FilesError['code'] { + switch (error.code) { + case StorageErrorCode.NOT_FOUND: + return 'NotFound'; + case StorageErrorCode.UNAUTHORIZED: + return 'Unauthorized'; + case StorageErrorCode.CONFLICT: + return error.permanent ? 'Conflict' : 'Provider'; + case StorageErrorCode.READ_ONLY: + return 'ReadOnly'; + default: + return 'Provider'; + } +} + +/** Converts NestM adapter failures before they enter the Files retry pipeline. */ +export function mapStorageErrorToFilesSdkError(error: unknown): FilesError { + if (error instanceof FilesError) return error; + if (!isStorageError(error)) return FilesError.wrap(error); + + const sanitized = sanitizedStorageError(error); + return new FilesError( + filesErrorCodeOfStorage(sanitized), + sanitized.message, + sanitized, + { + aborted: sanitized.aborted, + applied: sanitized.applied, + ...(isCanonicalStorageEtag(sanitized.appliedEtag) && { + appliedEtag: sanitized.appliedEtag, + }), + permanent: sanitized.permanent, + timedOut: sanitized.timedOut, + }, + ); +} + export function mapFilesSdkError(error: unknown): StorageError { if (isStorageError(error)) return sanitizedStorageError(error); if (!isFilesErrorLike(error)) { @@ -703,11 +904,22 @@ export function mapFilesSdkError(error: unknown): StorageError { filesError.code === 'Provider' && !filesError.aborted && !filesError.timedOut && - !filesError.permanent && isStorageError(filesError.cause) && filesError.message === filesError.cause.message ) { - return sanitizedStorageError(filesError.cause); + const storageCause = filesError.cause; + return new StorageError(PUBLIC_PROVIDER_ERROR_MESSAGES[storageCause.code], { + aborted: filesError.aborted || storageCause.aborted, + applied: filesError.applied === true || storageCause.applied, + ...(isCanonicalStorageEtag(filesError.appliedEtag) + ? { appliedEtag: filesError.appliedEtag } + : isCanonicalStorageEtag(storageCause.appliedEtag) + ? { appliedEtag: storageCause.appliedEtag } + : {}), + code: storageCause.code, + permanent: filesError.permanent || storageCause.permanent, + timedOut: filesError.timedOut || storageCause.timedOut, + }); } let code: StorageErrorCode; @@ -741,6 +953,10 @@ export function mapFilesSdkError(error: unknown): StorageError { return new StorageError(PUBLIC_PROVIDER_ERROR_MESSAGES[code], { aborted: filesError.aborted, + applied: filesError.applied === true, + ...(isCanonicalStorageEtag(filesError.appliedEtag) && { + appliedEtag: filesError.appliedEtag, + }), code, permanent: filesError.permanent, timedOut: filesError.timedOut, @@ -792,6 +1008,10 @@ function storageRetryOptions( error, { aborted: error.aborted, + applied: error.applied, + ...(isCanonicalStorageEtag(error.appliedEtag) && { + appliedEtag: error.appliedEtag, + }), permanent: error.permanent, timedOut: error.timedOut, }, @@ -921,6 +1141,25 @@ function uploadOptions( }; } +function conditionalUploadOptions( + options: StorageConditionalUploadOptions, +): ConditionalUploadOptions { + return { + ...operationOptions(options), + ...(options.cacheControl !== undefined && { + cacheControl: options.cacheControl, + }), + condition: options.condition, + ...(options.contentType !== undefined && { + contentType: options.contentType, + }), + ...(options.metadata !== undefined && { metadata: options.metadata }), + ...(options.onProgress !== undefined && { + onProgress: options.onProgress, + }), + }; +} + function downloadOptions(options?: StorageDownloadOptions): DownloadOptions { return { ...operationOptions(options), @@ -1026,6 +1265,19 @@ function invalidConditionalEtag( }); } +function invalidConditionalArgument( + message: string, + key: string, + operation: 'delete' | 'download' | 'promote' | 'upload', +): StorageError { + return new StorageError(message, { + code: StorageErrorCode.INVALID_ARGUMENT, + key, + operation, + permanent: true, + }); +} + function metadataOf( file: StoredFile, operation: 'download' | 'head' | 'list' | 'search', @@ -1059,6 +1311,64 @@ function uploadResultOf(result: UploadResult): StorageUploadResult { }; } +function conditionalUploadResultOf(result: UploadResult): StorageUploadResult { + try { + return uploadResultOf(result); + } catch (error) { + if (!isStorageError(error)) throw error; + throw new StorageError(error.message, { + applied: true, + code: error.code, + permanent: error.permanent, + }); + } +} + +function directConditionalUploadResultOf( + result: StorageUploadResult, + logicalKey: string, + physicalKey: string, +): StorageUploadResult { + let etag: string | undefined; + try { + etag = providerEtag(result.etag, logicalKey, 'upload'); + } catch (error) { + if (!isStorageError(error)) throw error; + throw new StorageError(error.message, { + applied: true, + code: error.code, + key: logicalKey, + operation: 'upload', + permanent: true, + }); + } + if (result.key !== physicalKey) { + throw new StorageError( + 'Storage adapter returned an unexpected conditional upload key.', + { + applied: true, + ...(etag !== undefined && { appliedEtag: etag }), + code: StorageErrorCode.PROVIDER, + key: logicalKey, + operation: 'upload', + permanent: true, + }, + ); + } + return { + ...result, + ...(etag === undefined ? {} : { etag }), + key: logicalKey, + }; +} + +function storageObjectOf(file: StoredFile): StorageObject { + return { + ...metadataOf(file, 'download'), + body: normalizeDownloadStream(file.stream()), + }; +} + function normalizeFilesSdkPrefix(prefix: unknown): string { if (prefix === undefined) return ''; if (typeof prefix !== 'string') { @@ -1270,7 +1580,7 @@ export class FilesSdkStorageDriver< readonly #conditionalDelete: FilesSdkConditionalDeleteAdapter | undefined; readonly #conditionalRead: FilesSdkConditionalReadAdapter | undefined; readonly #conditionalUpload: FilesSdkConditionalUploadAdapter | undefined; - readonly #conditionalOperationsBlockedByFilesPolicy: boolean; + readonly #directConditionalFallbackBlocked: boolean; readonly #physicalKey: FilesSdkPhysicalKeyAdapter | undefined; readonly #prefix: string; readonly #readOnly: boolean; @@ -1345,7 +1655,7 @@ export class FilesSdkStorageDriver< const readOnly = options.readonly === true || s3Provenance?.provenance === 'unverified'; // Evaluate caller policy before appending NestM's internal key guard below. - this.#conditionalOperationsBlockedByFilesPolicy = + this.#directConditionalFallbackBlocked = hasCallerFilesOperationPolicy(options); // Files retains the hooks object by reference. Snapshot it so an initially // inactive object cannot be mutated after this compatibility decision and @@ -1397,6 +1707,30 @@ export class FilesSdkStorageDriver< get capabilities() { const capabilities = this.#files.capabilities; + const conditional = capabilities.conditional; + const directFallbackAllowed = !this.#directConditionalFallbackBlocked; + const directCopy = directFallbackAllowed + ? this.#conditionalCopy + : undefined; + const pipelineCopy = conditional.copy; + const copyCapabilities = conditionalCopyCapabilities( + directCopy, + pipelineCopy, + ); + const conditionalCopySource = copyCapabilities.source; + const conditionalCopyDestination = copyCapabilities.destination; + const directDelete = directFallbackAllowed + ? this.#conditionalDelete + : undefined; + const directRead = directFallbackAllowed + ? this.#conditionalRead + : undefined; + const directUpload = directFallbackAllowed + ? this.#conditionalUpload + : undefined; + const conditionalReadEtag = + conditional.exactRead || directRead?.conditionalRead.etag === true; + const conditionalReadVersion = directRead?.conditionalRead.version === true; return { cacheControl: capabilities.cacheControl, delimiter: capabilities.delimiter, @@ -1404,51 +1738,48 @@ export class FilesSdkStorageDriver< rangeRead: capabilities.rangeRead, resumableUpload: !this.#readOnly && capabilities.multipart, serverSideCopy: !this.#readOnly && capabilities.serverSideCopy, - ...(!this.#conditionalOperationsBlockedByFilesPolicy && - this.#conditionalCopy !== undefined && - !this.#readOnly && { - ...(this.#conditionalCopy.conditionalCopySource !== undefined && { - conditionalCopySource: { - ...this.#conditionalCopy.conditionalCopySource, - }, - }), - ...(this.#conditionalCopy.conditionalCopyDestination !== - undefined && { - conditionalCopyDestination: { - ...this.#conditionalCopy.conditionalCopyDestination, - }, - }), + ...(!this.#readOnly && + conditionalCopySource !== undefined && { + conditionalCopySource, }), - ...(!this.#conditionalOperationsBlockedByFilesPolicy && - this.#conditionalDelete !== undefined && - !this.#readOnly && { - conditionalDelete: { - ...this.#conditionalDelete.conditionalDelete, + ...(!this.#readOnly && + conditionalCopyDestination !== undefined && { + conditionalCopyDestination, + }), + ...(!this.#readOnly && + (conditional.delete || + directDelete?.conditionalDelete.etag === true) && { + conditionalDelete: { etag: true }, + }), + ...((conditionalReadEtag || conditionalReadVersion) && { + conditionalRead: { + etag: conditionalReadEtag, + version: conditionalReadVersion, + }, + }), + ...(!this.#readOnly && + (conditional.create || + directUpload?.conditionalCreate !== undefined) && { + conditionalCreate: { + resultEtag: + conditional.create || + directUpload?.conditionalCreate?.resultEtag === true, }, }), - ...(!this.#conditionalOperationsBlockedByFilesPolicy && - this.#conditionalRead !== undefined && { - conditionalRead: { ...this.#conditionalRead.conditionalRead }, + ...(!this.#readOnly && + (conditional.replace || + directUpload?.conditionalReplace !== undefined) && { + conditionalReplace: { + resultEtag: + conditional.replace || + directUpload?.conditionalReplace?.resultEtag === true, + }, }), - ...(!this.#conditionalOperationsBlockedByFilesPolicy && - this.#conditionalUpload !== undefined && - !this.#readOnly && { - ...(this.#conditionalUpload.conditionalCreate !== undefined && { - conditionalCreate: { - ...this.#conditionalUpload.conditionalCreate, - }, - }), - ...(this.#conditionalUpload.conditionalReplace !== undefined && { - conditionalReplace: { - ...this.#conditionalUpload.conditionalReplace, - }, - }), - ...(this.#conditionalUpload.conditionalMultipartCompletion !== - undefined && { - conditionalMultipartCompletion: { - ...this.#conditionalUpload.conditionalMultipartCompletion, - }, - }), + ...(!this.#readOnly && + directUpload?.conditionalMultipartCompletion !== undefined && { + conditionalMultipartCompletion: { + ...directUpload.conditionalMultipartCompletion, + }, }), ...(this.#physicalKey !== undefined && { physicalKey: { ...this.#physicalKey.physicalKey }, @@ -1488,17 +1819,31 @@ export class FilesSdkStorageDriver< body: StorageBody, options: StorageConditionalUploadOptions, ): Promise { + const condition = (options as { readonly condition?: unknown } | undefined) + ?.condition; if ( - options.condition.type === 'replace' && - !isCanonicalStorageEtag(options.condition.etag) + typeof condition !== 'object' || + condition === null || + !('type' in condition) || + (condition.type !== 'create' && condition.type !== 'replace') ) { return Promise.reject( - invalidConditionalEtag('condition.etag', key, 'upload'), + invalidConditionalArgument( + 'condition.type must be "create" or "replace".', + key, + 'upload', + ), ); } - if (this.#conditionalOperationsBlockedByFilesPolicy) { - return Promise.reject(this.#conditionalFilesPolicyError(key, 'upload')); + if ( + condition.type === 'replace' && + !isCanonicalStorageEtag('etag' in condition ? condition.etag : undefined) + ) { + return Promise.reject( + invalidConditionalEtag('condition.etag', key, 'upload'), + ); } + this.#assertLogicalKey(key); if (this.#readOnly) { return Promise.reject( new StorageError( @@ -1514,21 +1859,42 @@ export class FilesSdkStorageDriver< } const adapter = this.#conditionalUpload; const capability = - options.condition.type === 'create' + condition.type === 'create' ? adapter?.conditionalCreate : adapter?.conditionalReplace; const multipartRequested = options.multipart !== undefined && options.multipart !== false; + const controlRequested = options.control !== undefined; + const pipelineSupported = + !multipartRequested && + !controlRequested && + (condition.type === 'create' + ? this.#files.capabilities.conditional.create + : this.#files.capabilities.conditional.replace); + if (pipelineSupported) { + return this.#call(async () => + conditionalUploadResultOf( + await this.#files.upload( + key, + mapBody(body), + conditionalUploadOptions(options), + ), + ), + ); + } const multipartSupported = !multipartRequested || - (options.condition.type === 'create' + (condition.type === 'create' ? adapter?.conditionalMultipartCompletion?.create === true : adapter?.conditionalMultipartCompletion?.replace === true); - if ( - adapter === undefined || - capability === undefined || - !multipartSupported - ) { + const directFallbackSupported = + adapter !== undefined && capability !== undefined && multipartSupported; + if (directFallbackSupported && this.#directConditionalFallbackBlocked) { + return Promise.reject( + this.#conditionalFallbackPolicyError(key, 'upload'), + ); + } + if (!directFallbackSupported) { return Promise.reject( new StorageError( `Storage adapter "${this.#name}" does not support conditional upload.`, @@ -1549,23 +1915,7 @@ export class FilesSdkStorageDriver< mapBody(body), mergedOptions, ); - if (result.key !== physicalKey) { - throw new StorageError( - 'Storage adapter returned an unexpected conditional upload key.', - { - code: StorageErrorCode.PROVIDER, - key, - operation: 'upload', - permanent: true, - }, - ); - } - const etag = providerEtag(result.etag, key, 'upload'); - return { - ...result, - ...(etag === undefined ? {} : { etag }), - key, - }; + return directConditionalUploadResultOf(result, key, physicalKey); }); } @@ -1587,24 +1937,71 @@ export class FilesSdkStorageDriver< key: string, options: StorageConditionalReadOptions, ): Promise { + const condition = (options as { readonly condition?: unknown } | undefined) + ?.condition; + if (typeof condition !== 'object' || condition === null) { + return Promise.reject( + invalidConditionalArgument( + 'conditional read requires a condition object.', + key, + 'download', + ), + ); + } + const etag = 'etag' in condition ? condition.etag : undefined; + const version = 'version' in condition ? condition.version : undefined; + if (etag !== undefined && !isCanonicalStorageEtag(etag)) { + return Promise.reject( + invalidConditionalEtag('condition.etag', key, 'download'), + ); + } if ( - options.condition.etag !== undefined && - !isCanonicalStorageEtag(options.condition.etag) + version !== undefined && + (typeof version !== 'string' || version.length === 0) ) { return Promise.reject( - invalidConditionalEtag('condition.etag', key, 'download'), + invalidConditionalArgument( + 'condition.version must be a non-empty string.', + key, + 'download', + ), ); } - if (this.#conditionalOperationsBlockedByFilesPolicy) { - return Promise.reject(this.#conditionalFilesPolicyError(key, 'download')); + if (etag === undefined && version === undefined) { + return Promise.reject( + invalidConditionalArgument( + 'conditional read requires an etag, version, or both.', + key, + 'download', + ), + ); + } + this.#assertLogicalKey(key); + const pipelineSupported = + etag !== undefined && + version === undefined && + this.#files.capabilities.conditional.exactRead; + if (pipelineSupported) { + return this.#call(async () => + storageObjectOf( + await this.#files.download(key, { + ...downloadOptions(options), + condition: { etag }, + }), + ), + ); } const adapter = this.#conditionalRead; - if ( - adapter === undefined || - (options.condition.etag !== undefined && !adapter.conditionalRead.etag) || - (options.condition.version !== undefined && - !adapter.conditionalRead.version) - ) { + const directFallbackSupported = + adapter !== undefined && + (etag === undefined || adapter.conditionalRead.etag) && + (version === undefined || adapter.conditionalRead.version); + if (directFallbackSupported && this.#directConditionalFallbackBlocked) { + return Promise.reject( + this.#conditionalFallbackPolicyError(key, 'download'), + ); + } + if (!directFallbackSupported) { return Promise.reject( new StorageError( `Storage adapter "${this.#name}" does not support conditional read.`, @@ -1681,14 +2078,24 @@ export class FilesSdkStorageDriver< key: string, options: StorageConditionalDeleteOptions, ): Promise { - if (!isCanonicalStorageEtag(options.condition.etag)) { + const condition = (options as { readonly condition?: unknown } | undefined) + ?.condition; + if (typeof condition !== 'object' || condition === null) { return Promise.reject( - invalidConditionalEtag('condition.etag', key, 'delete'), + invalidConditionalArgument( + 'conditional delete requires a condition object.', + key, + 'delete', + ), ); } - if (this.#conditionalOperationsBlockedByFilesPolicy) { - return Promise.reject(this.#conditionalFilesPolicyError(key, 'delete')); + const etag = 'etag' in condition ? condition.etag : undefined; + if (!isCanonicalStorageEtag(etag)) { + return Promise.reject( + invalidConditionalEtag('condition.etag', key, 'delete'), + ); } + this.#assertLogicalKey(key); if (this.#readOnly) { return Promise.reject( new StorageError( @@ -1702,8 +2109,23 @@ export class FilesSdkStorageDriver< ), ); } + if (this.#files.capabilities.conditional.delete) { + return this.#call(() => + this.#files.delete(key, { + ...operationOptions(options), + condition: options.condition, + }), + ); + } const adapter = this.#conditionalDelete; - if (adapter === undefined || !adapter.conditionalDelete.etag) { + const directFallbackSupported = + adapter !== undefined && adapter.conditionalDelete.etag; + if (directFallbackSupported && this.#directConditionalFallbackBlocked) { + return Promise.reject( + this.#conditionalFallbackPolicyError(key, 'delete'), + ); + } + if (!directFallbackSupported) { return Promise.reject( new StorageError( `Storage adapter "${this.#name}" does not support conditional delete.`, @@ -1751,6 +2173,15 @@ export class FilesSdkStorageDriver< destinationKey: string, options: StoragePromotionOptions, ): Promise { + if (typeof options !== 'object' || options === null) { + return Promise.reject( + invalidConditionalArgument( + 'promote requires an options object with a precondition.', + sourceKey, + 'promote', + ), + ); + } const destination = options.destination; if ( destination !== undefined && @@ -1767,6 +2198,32 @@ export class FilesSdkStorageDriver< }), ); } + if ( + options.sourceVersion !== undefined && + (typeof options.sourceVersion !== 'string' || + options.sourceVersion.length === 0) + ) { + return Promise.reject( + invalidConditionalArgument( + 'sourceVersion must be a non-empty string.', + sourceKey, + 'promote', + ), + ); + } + if ( + options.sourceEtag === undefined && + options.sourceVersion === undefined && + destination === undefined + ) { + return Promise.reject( + invalidConditionalArgument( + 'promote requires a source or destination precondition.', + sourceKey, + 'promote', + ), + ); + } if ( options.sourceEtag !== undefined && !isCanonicalStorageEtag(options.sourceEtag) @@ -1783,11 +2240,8 @@ export class FilesSdkStorageDriver< invalidConditionalEtag('destination.etag', destinationKey, 'promote'), ); } - if (this.#conditionalOperationsBlockedByFilesPolicy) { - return Promise.reject( - this.#conditionalFilesPolicyError(sourceKey, 'promote'), - ); - } + this.#assertLogicalKey(sourceKey); + this.#assertLogicalKey(destinationKey); if (this.#readOnly) { return Promise.reject( new StorageError( @@ -1801,22 +2255,61 @@ export class FilesSdkStorageDriver< ), ); } + const pipelineCopy = this.#files.capabilities.conditional.copy; + const pipelineDestinationSupported = + destination?.type === 'create' + ? pipelineCopy.destinationCreate + : destination?.type === 'replace' + ? pipelineCopy.destinationReplace + : false; + const pipelineSupported = + options.sourceEtag !== undefined && + options.sourceVersion === undefined && + destination !== undefined && + pipelineCopy.sourceEtag && + pipelineCopy.atomicSourceDestination && + pipelineDestinationSupported; + if (pipelineSupported) { + return this.#call(() => + this.#files.copy(sourceKey, destinationKey, { + ...operationOptions(options), + condition: { + destination, + source: { etag: options.sourceEtag as string }, + }, + }), + ); + } const adapter = this.#conditionalCopy; - if ( - adapter === undefined || - (options.sourceEtag !== undefined && - adapter.conditionalCopySource?.etag !== true) || - (options.sourceVersion !== undefined && - adapter.conditionalCopySource?.version !== true) || - (options.destination?.type === 'create' && - adapter.conditionalCopyDestination?.create !== true) || - (options.destination?.type === 'replace' && - adapter.conditionalCopyDestination?.replace !== true) || - (options.destination !== undefined && - (options.sourceEtag !== undefined || - options.sourceVersion !== undefined) && - adapter.conditionalCopyDestination?.atomicWithSource !== true) - ) { + const directFallbackSupported = + adapter !== undefined && + (options.sourceEtag === undefined || + adapter.conditionalCopySource?.etag === true) && + (options.sourceVersion === undefined || + adapter.conditionalCopySource?.version === true) && + (options.sourceEtag === undefined && options.sourceVersion === undefined + ? true + : options.destination !== undefined || + adapter.conditionalCopySource?.requiresDestinationPredicate !== + true) && + (options.destination?.type !== 'create' || + adapter.conditionalCopyDestination?.create === true) && + (options.destination?.type !== 'replace' || + adapter.conditionalCopyDestination?.replace === true) && + (options.destination === undefined || + options.sourceEtag !== undefined || + options.sourceVersion !== undefined || + adapter.conditionalCopyDestination?.requiresSourcePredicate !== true) && + (options.destination === undefined || + (options.sourceEtag === undefined && + options.sourceVersion === undefined) || + adapter.conditionalCopyDestination?.atomicWithSource === true); + if (directFallbackSupported && this.#directConditionalFallbackBlocked) { + return Promise.reject( + this.#conditionalFallbackPolicyError(sourceKey, 'promote'), + ); + } + if (!directFallbackSupported) { return Promise.reject( new StorageError( `Storage adapter "${this.#name}" does not support conditional promotion.`, @@ -1971,12 +2464,12 @@ export class FilesSdkStorageDriver< ); } - #conditionalFilesPolicyError( + #conditionalFallbackPolicyError( key: string, operation: 'delete' | 'download' | 'promote' | 'upload', ): StorageError { return new StorageError( - 'Conditional storage operations are unavailable while Files SDK plugins, hooks, or receipts are configured.', + 'This conditional form requires a NestM adapter fallback and is unavailable while Files SDK plugins, hooks, or receipts are configured.', { code: StorageErrorCode.NOT_SUPPORTED, key, diff --git a/src/files-sdk/fs/fs.driver.spec.ts b/src/files-sdk/fs/fs.driver.spec.ts index 21b8f87..f0eeb97 100644 --- a/src/files-sdk/fs/fs.driver.spec.ts +++ b/src/files-sdk/fs/fs.driver.spec.ts @@ -8,12 +8,25 @@ import { symlinkSync, writeFileSync, } from 'node:fs'; +import * as fsp from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import type { FilesError } from 'files-sdk'; +import { fs } from 'files-sdk/fs'; + import { StorageClient } from '../../storage.client.js'; import { StorageErrorCode } from '../../storage.error.js'; -import { createFsStorageDriver } from './index.js'; +import { createFsStorageDriver, withFsConditionalMutation } from './index.js'; + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + rename: vi.fn(actual.rename), + unlink: vi.fn(actual.unlink), + }; +}); describe('createFsStorageDriver', () => { let root = ''; @@ -175,6 +188,107 @@ describe('createFsStorageDriver', () => { await expect(client.exists('note.txt')).resolves.toBe(false); }); + it('exposes Files SDK native conditional primitives with normalized results and errors', async () => { + const adapter = withFsConditionalMutation(fs({ root })); + const { + copy, + create, + delete: deleteExact, + exactRead, + replace, + } = adapter.conditional ?? {}; + if (!copy || !create || !deleteExact || !exactRead || !replace) { + throw new Error('Expected every filesystem conditional primitive.'); + } + + expect(Object.isFrozen(adapter.conditional)).toBe(true); + expect(Object.isFrozen(copy)).toBe(true); + expect(copy).toMatchObject({ + atomicSourceDestination: true, + destinationCreate: true, + destinationReplace: true, + sourceEtag: true, + }); + + const created = await create('native.txt', 'first', { + contentType: 'text/plain', + metadata: { owner: 'files-sdk' }, + }); + expect(created).toEqual( + expect.objectContaining({ + contentType: 'text/plain', + etag: expect.any(String), + key: 'native.txt', + lastModified: expect.any(Number), + size: 5, + }), + ); + const exact = await exactRead('native.txt', created.etag); + await expect(exact.text()).resolves.toBe('first'); + expect(exact).toMatchObject({ + etag: created.etag, + metadata: { owner: 'files-sdk' }, + type: 'text/plain', + }); + + await expect(replace('native.txt', 'wrong', 'stale-etag')).rejects.toEqual( + expect.objectContaining>({ + code: 'Conflict', + name: 'FilesError', + permanent: true, + }), + ); + const replaced = await replace('native.txt', 'second', created.etag); + await copy.run('native.txt', 'copy.txt', { + destination: { type: 'create' }, + source: { etag: replaced.etag }, + }); + await expect( + (await exactRead('copy.txt', replaced.etag)).text(), + ).resolves.toBe('second'); + + await expect(deleteExact('native.txt', created.etag)).rejects.toEqual( + expect.objectContaining>({ + code: 'Conflict', + name: 'FilesError', + }), + ); + await deleteExact('native.txt', replaced.etag); + }); + + it('rejects empty or malformed predicates at the decorated adapter boundary', async () => { + const adapter = withFsConditionalMutation(fs({ root })); + + await expect( + adapter.downloadConditional('unsafe-read.txt', { + condition: {}, + } as never), + ).rejects.toMatchObject({ + code: StorageErrorCode.INVALID_ARGUMENT, + permanent: true, + }); + await expect( + adapter.promote('unsafe-source.txt', 'unsafe-destination.txt', {}), + ).rejects.toMatchObject({ + code: StorageErrorCode.INVALID_ARGUMENT, + permanent: true, + }); + await expect( + adapter.uploadConditional('unsafe-upload.txt', 'body', { + condition: { type: 'invalid' }, + } as never), + ).rejects.toMatchObject({ + code: StorageErrorCode.INVALID_ARGUMENT, + permanent: true, + }); + await expect( + adapter.deleteConditional('unsafe-delete.txt', {} as never), + ).rejects.toMatchObject({ + code: StorageErrorCode.INVALID_ARGUMENT, + permanent: true, + }); + }); + it('serializes conditional creates across drivers for the same root', async () => { const first = new StorageClient( 'first', @@ -202,6 +316,294 @@ describe('createFsStorageDriver', () => { ).toMatchObject([{ reason: { code: StorageErrorCode.CONFLICT } }]); }); + it('serializes ordinary uploads with conditional uploads', async () => { + const adapter = withFsConditionalMutation(fs({ root })); + let releaseBody = (): void => undefined; + let reportBodyRead = (): void => undefined; + const bodyRead = new Promise((resolve) => { + reportBodyRead = resolve; + }); + const bodyReleased = new Promise((resolve) => { + releaseBody = resolve; + }); + const conditional = adapter.uploadConditional( + 'nested/shared.txt', + new ReadableStream({ + async pull(controller) { + reportBodyRead(); + await bodyReleased; + controller.enqueue(new TextEncoder().encode('conditional')); + controller.close(); + }, + }), + { condition: { type: 'create' } }, + ); + await bodyRead; + + let ordinarySettled = false; + const ordinary = adapter + .upload('nested//shared.txt', 'ordinary') + .finally(() => { + ordinarySettled = true; + }); + try { + await new Promise((resolve) => setImmediate(resolve)); + expect(ordinarySettled).toBe(false); + } finally { + releaseBody(); + } + + await expect(conditional).resolves.toMatchObject({ + key: 'nested/shared.txt', + }); + await expect(ordinary).resolves.toMatchObject({ + key: 'nested//shared.txt', + }); + await expect( + (await adapter.download('nested/shared.txt')).text(), + ).resolves.toBe('ordinary'); + }); + + it('honors aborts and timeouts while conditional calls wait for a filesystem lock', async () => { + const adapter = withFsConditionalMutation(fs({ root })); + let releaseBody = (): void => undefined; + let reportBodyRead = (): void => undefined; + const bodyRead = new Promise((resolve) => { + reportBodyRead = resolve; + }); + const bodyReleased = new Promise((resolve) => { + releaseBody = resolve; + }); + const held = adapter.uploadConditional( + 'queued.txt', + new ReadableStream({ + async pull(controller) { + reportBodyRead(); + await bodyReleased; + controller.enqueue(new TextEncoder().encode('held')); + controller.close(); + }, + }), + { condition: { type: 'create' } }, + ); + await bodyRead; + + const controller = new AbortController(); + const aborted = adapter.uploadConditional('queued.txt', 'aborted', { + condition: { type: 'create' }, + signal: controller.signal, + }); + const timedOut = adapter.uploadConditional('queued.txt', 'timed out', { + condition: { type: 'create' }, + timeout: 10, + }); + const queued = Promise.allSettled([aborted, timedOut]); + controller.abort(new Error('cancel queued upload')); + let timeout: ReturnType | undefined; + const deadline = new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error('queued operations ignored cancellation')), + 500, + ); + }); + + try { + const settled = await Promise.race([queued, deadline]); + expect(settled).toMatchObject([ + { + reason: { + aborted: true, + code: StorageErrorCode.ABORTED, + timedOut: false, + }, + status: 'rejected', + }, + { + reason: { + code: StorageErrorCode.TIMEOUT, + timedOut: true, + }, + status: 'rejected', + }, + ]); + } finally { + if (timeout !== undefined) { + clearTimeout(timeout); + } + releaseBody(); + } + await expect(held).resolves.toMatchObject({ key: 'queued.txt' }); + }); + + it('uses a strict lock order for reverse promotions', async () => { + const adapter = withFsConditionalMutation(fs({ root })); + const first = await adapter.uploadConditional('a', 'first', { + condition: { type: 'create' }, + }); + const second = await adapter.uploadConditional('a\u200b', 'second', { + condition: { type: 'create' }, + }); + if (first.etag === undefined || second.etag === undefined) { + throw new Error('Expected canonical ETags from conditional uploads.'); + } + const promotions = Promise.allSettled([ + adapter.promote('a', 'a\u200b', { + destination: { etag: second.etag, type: 'replace' }, + sourceEtag: first.etag, + }), + adapter.promote('a\u200b', 'a', { + destination: { etag: first.etag, type: 'replace' }, + sourceEtag: second.etag, + }), + ]); + let timeout: ReturnType | undefined; + const timedOut = new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error('promotion deadlocked')), + 500, + ); + }); + + try { + const settled = await Promise.race([promotions, timedOut]); + expect(settled).toHaveLength(2); + } finally { + if (timeout !== undefined) { + clearTimeout(timeout); + } + } + }); + + it('marks a conditional upload applied when its sidecar commit fails', async () => { + const adapter = withFsConditionalMutation(fs({ root })); + const actual = + await vi.importActual( + 'node:fs/promises', + ); + const rename = vi.mocked(fsp.rename); + rename.mockImplementation(async (oldPath, newPath) => { + if (String(newPath).endsWith('.meta.json')) { + throw Object.assign(new Error('injected sidecar rename failure'), { + code: 'EIO', + }); + } + await actual.rename(oldPath, newPath); + }); + + try { + await expect( + adapter.uploadConditional('partial.txt', 'committed body', { + condition: { type: 'create' }, + }), + ).rejects.toMatchObject({ + applied: true, + appliedEtag: expect.any(String), + code: StorageErrorCode.PROVIDER, + permanent: true, + }); + expect(readFileSync(join(root, 'partial.txt'), 'utf8')).toBe( + 'committed body', + ); + } finally { + rename.mockImplementation(actual.rename); + } + }); + + it('marks a conditional delete applied when sidecar removal fails', async () => { + const adapter = withFsConditionalMutation(fs({ root })); + const created = await adapter.uploadConditional( + 'partial-delete.txt', + 'body', + { condition: { type: 'create' } }, + ); + if (created.etag === undefined) { + throw new Error('Expected a canonical ETag from conditional upload.'); + } + const actual = + await vi.importActual( + 'node:fs/promises', + ); + const unlink = vi.mocked(fsp.unlink); + unlink.mockImplementation(async (filePath) => { + if (String(filePath).endsWith('.meta.json')) { + throw Object.assign(new Error('injected sidecar unlink failure'), { + code: 'EIO', + }); + } + await actual.unlink(filePath); + }); + + try { + await expect( + adapter.deleteConditional('partial-delete.txt', { + condition: { etag: created.etag }, + }), + ).rejects.toMatchObject({ + applied: true, + appliedEtag: undefined, + code: StorageErrorCode.PROVIDER, + permanent: true, + }); + expect(existsSync(join(root, 'partial-delete.txt'))).toBe(false); + expect(existsSync(join(root, 'partial-delete.txt.meta.json'))).toBe(true); + } finally { + unlink.mockImplementation(actual.unlink); + } + }); + + it('marks a conditional promotion applied when its sidecar commit fails', async () => { + const adapter = withFsConditionalMutation(fs({ root })); + const source = await adapter.uploadConditional( + 'source.txt', + 'source body', + { condition: { type: 'create' } }, + ); + const destination = await adapter.uploadConditional( + 'destination.txt', + 'destination body', + { condition: { type: 'create' } }, + ); + if (source.etag === undefined || destination.etag === undefined) { + throw new Error('Expected canonical ETags from conditional uploads.'); + } + const actual = + await vi.importActual( + 'node:fs/promises', + ); + const rename = vi.mocked(fsp.rename); + rename.mockImplementation(async (oldPath, newPath) => { + if (String(newPath).endsWith('destination.txt.meta.json')) { + throw Object.assign(new Error('injected sidecar rename failure'), { + code: 'EIO', + }); + } + await actual.rename(oldPath, newPath); + }); + + try { + await expect( + adapter.promote('source.txt', 'destination.txt', { + destination: { + etag: destination.etag, + type: 'replace', + }, + sourceEtag: source.etag, + }), + ).rejects.toMatchObject({ + applied: true, + appliedEtag: undefined, + code: StorageErrorCode.PROVIDER, + key: 'destination.txt', + permanent: true, + }); + expect(readFileSync(join(root, 'destination.txt'), 'utf8')).toBe( + 'source body', + ); + } finally { + rename.mockImplementation(actual.rename); + } + }); + it('does not advertise conditional mutations from a readonly filesystem driver', () => { const driver = createFsStorageDriver({ adapter: { root }, diff --git a/src/files-sdk/fs/index.ts b/src/files-sdk/fs/index.ts index eb50676..80ab741 100644 --- a/src/files-sdk/fs/index.ts +++ b/src/files-sdk/fs/index.ts @@ -4,13 +4,19 @@ import * as fsp from 'node:fs/promises'; import path from 'node:path'; import { Readable } from 'node:stream'; -import type { - Body, - DownloadOptions, - ListOptions, - ListResult, - OperationOptions, - StoredFile, +import { + createStoredFile, + type AdapterConditionalOperations, + type AdapterDownloadOptions, + type AdapterUploadOptions, + type Body, + type ConditionalUploadResult, + type CopyCondition, + type DownloadOptions, + type ListOptions, + type ListResult, + type OperationOptions, + type StoredFile, } from 'files-sdk'; import { fs, type FsAdapter, type FsAdapterOptions } from 'files-sdk/fs'; @@ -30,6 +36,7 @@ import type { } from '../../storage.types.js'; import { createFilesSdkDriver, + mapStorageErrorToFilesSdkError, type FilesSdkConditionalCopyAdapter, type FilesSdkConditionalDeleteAdapter, type FilesSdkConditionalReadAdapter, @@ -71,6 +78,54 @@ interface OperationRuntime { callerSignal: AbortSignal | undefined; } +interface OperationRuntimeOptions { + readonly signal?: AbortSignal; + readonly timeout?: number; +} + +function storedFileOfStorageObject(object: StorageObject): StoredFile { + return createStoredFile( + { + ...(object.etag !== undefined && { etag: object.etag }), + key: object.key, + ...(object.lastModified !== undefined && { + lastModified: object.lastModified.getTime(), + }), + ...(object.metadata !== undefined && { + metadata: { ...object.metadata }, + }), + size: object.size, + type: object.contentType, + }, + { factory: () => object.body, kind: 'stream' }, + ); +} + +function conditionalUploadResultOf( + result: StorageUploadResult, +): ConditionalUploadResult { + if (!isCanonicalStorageEtag(result.etag)) { + throw new StorageError( + 'A conditional filesystem upload committed without a canonical ETag.', + { + applied: true, + code: StorageErrorCode.PROVIDER, + operation: 'upload', + permanent: true, + }, + ); + } + return { + contentType: result.contentType, + etag: result.etag, + key: result.key, + ...(result.lastModified !== undefined && { + lastModified: result.lastModified.getTime(), + }), + size: result.size, + }; +} + function fsErrorCode(error: unknown): string | undefined { if (typeof error === 'object' && error !== null && 'code' in error) { return typeof error.code === 'string' ? error.code : undefined; @@ -106,6 +161,25 @@ function storageFsError( }); } +function appliedStorageFsError( + error: unknown, + key: string, + operation: 'copy' | 'delete' | 'upload', + appliedEtag?: string, +): StorageError { + const mapped = storageFsError(error, key, operation); + return new StorageError(mapped.message, { + aborted: mapped.aborted, + applied: true, + ...(appliedEtag !== undefined && { appliedEtag }), + code: mapped.code, + key: mapped.key ?? key, + operation: mapped.operation ?? operation, + permanent: true, + timedOut: mapped.timedOut, + }); +} + function invalidFsPath(key: string, message: string): never { throw new StorageError(`Invalid filesystem storage key: ${message}.`, { code: StorageErrorCode.INVALID_ARGUMENT, @@ -175,7 +249,7 @@ function notFound(key: string): never { }); } -function runtimeOf(options: StorageOperationOptions): OperationRuntime { +function runtimeOf(options: OperationRuntimeOptions): OperationRuntime { const timeoutSignal = options.timeout === undefined || options.timeout <= 0 ? undefined @@ -559,6 +633,7 @@ const CONDITIONAL_FS_TAILS = new Map>(); async function serializeFsKey( lockKey: string, operation: () => Promise, + wait?: { readonly key: string; readonly runtime: OperationRuntime }, ): Promise { const previous = CONDITIONAL_FS_TAILS.get(lockKey) ?? Promise.resolve(); let release = (): void => undefined; @@ -567,13 +642,53 @@ async function serializeFsKey( }); const tail = previous.then(() => gate); CONDITIONAL_FS_TAILS.set(lockKey, tail); - await previous; + let acquired = false; try { + if (wait === undefined || wait.runtime.signal === undefined) { + await previous; + } else { + const signal = wait.runtime.signal; + await new Promise((resolve, reject) => { + const cleanup = (): void => signal.removeEventListener('abort', abort); + const abort = (): void => { + cleanup(); + try { + assertActive(wait.runtime, wait.key); + } catch (error) { + reject(error); + } + }; + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) { + abort(); + return; + } + void previous.then( + () => { + cleanup(); + resolve(); + }, + (error: unknown) => { + cleanup(); + reject(error); + }, + ); + }); + } + acquired = true; return await operation(); } finally { release(); - if (CONDITIONAL_FS_TAILS.get(lockKey) === tail) { - CONDITIONAL_FS_TAILS.delete(lockKey); + if (acquired) { + if (CONDITIONAL_FS_TAILS.get(lockKey) === tail) { + CONDITIONAL_FS_TAILS.delete(lockKey); + } + } else { + void tail.then(() => { + if (CONDITIONAL_FS_TAILS.get(lockKey) === tail) { + CONDITIONAL_FS_TAILS.delete(lockKey); + } + }); } } } @@ -582,33 +697,65 @@ async function serializeFsKey( * Adds symlink-defended reads and process-local exact conditional operations * to a files-sdk fs adapter. * - * The configured root must be dedicated to this driver: no other process and - * no unconditional filesystem writer may mutate it concurrently. Each call - * rejects symlinks in the existing key path and uses exclusive same-directory - * temporary files plus atomic renames, but Node has no portable `openat2` - * equivalent that could make those checks safe against a hostile concurrent - * tree rewrite. This is a CAS implementation for an exclusively-owned local - * workspace, not a host-filesystem sandbox. Body and metadata sidecars are two - * files, so a process crash between their renames can still require cleanup. + * The configured root must be dedicated to this decorated adapter: no other + * process, unwrapped adapter, or direct filesystem writer may mutate it + * concurrently. Ordinary adapter mutations and conditional operations share + * one process-local lock domain. Each call rejects symlinks in the existing key + * path and uses exclusive same-directory temporary files plus atomic renames, + * but Node has no portable `openat2` equivalent that could make those checks + * safe against a hostile concurrent tree rewrite. This is a CAS implementation + * for an exclusively-owned local workspace, not a host-filesystem sandbox. + * Body and metadata sidecars are two files, so a process crash between their + * renames can still require cleanup. */ export function withFsConditionalMutation(base: FsAdapter): FsStorageAdapter { const root = path.resolve(base.root); + const copy = base.copy.bind(base); + const deleteObject = base.delete.bind(base); const download = base.download.bind(base); const exists = base.exists.bind(base); const head = base.head.bind(base); const list = base.list.bind(base); + const move = base.move?.bind(base); + const resumableUpload = base.resumableUpload?.bind(base); + const upload = base.upload.bind(base); + const identity = (key: string): string => + path.resolve(root, key).normalize('NFC').toLowerCase(); const serialize = ( key: string, operation: () => Promise, + runtime?: OperationRuntime, ): Promise => - serializeFsKey(`${root}\0${key.normalize('NFC').toLowerCase()}`, operation); + serializeFsKey( + identity(key), + operation, + runtime === undefined ? undefined : { key, runtime }, + ); + const serializeMutationPair = ( + first: string, + second: string, + operation: () => Promise, + runtime?: OperationRuntime, + ): Promise => { + const firstIdentity = identity(first); + const secondIdentity = identity(second); + if (firstIdentity === secondIdentity) { + return serialize(first, operation, runtime); + } + const [headKey, tailKey] = + firstIdentity < secondIdentity ? [first, second] : [second, first]; + return serialize( + headKey, + () => serialize(tailKey, operation, runtime), + runtime, + ); + }; const serializePair = ( first: string, second: string, operation: () => Promise, + runtime?: OperationRuntime, ): Promise => { - const identity = (key: string): string => - key.normalize('NFC').toLowerCase(); if (identity(first) === identity(second)) { throw new StorageError( 'Conditional filesystem copy requires distinct source and destination keys.', @@ -620,22 +767,32 @@ export function withFsConditionalMutation(base: FsAdapter): FsStorageAdapter { }, ); } - const ordered = [first, second].sort((left, right) => - identity(left).localeCompare(identity(right)), - ); - const [headKey, tailKey] = ordered; - if (headKey === undefined || tailKey === undefined) { - throw new StorageError( - 'Conditional filesystem copy requires distinct source and destination keys.', - { - code: StorageErrorCode.INVALID_ARGUMENT, - key: first, - operation: 'copy', - permanent: true, - }, - ); + return serializeMutationPair(first, second, operation, runtime); + }; + const serializeFilesMutation = async ( + key: string, + options: OperationOptions | undefined, + operation: () => Promise, + ): Promise => { + const runtime = runtimeOf(options ?? {}); + try { + return await serialize(key, operation, runtime); + } catch (error) { + throw mapStorageErrorToFilesSdkError(error); + } + }; + const serializeFilesMutationPair = async ( + first: string, + second: string, + options: OperationOptions | undefined, + operation: () => Promise, + ): Promise => { + const runtime = runtimeOf(options ?? {}); + try { + return await serializeMutationPair(first, second, operation, runtime); + } catch (error) { + throw mapStorageErrorToFilesSdkError(error); } - return serialize(headKey, () => serialize(tailKey, operation)); }; const assertEtag = ( value: unknown, @@ -653,7 +810,7 @@ export function withFsConditionalMutation(base: FsAdapter): FsStorageAdapter { } }; - return Object.assign(base, { + const adapter = Object.assign(base, { conditionalCreate: Object.freeze({ resultEtag: true }), conditionalReplace: Object.freeze({ resultEtag: true }), conditionalDelete: Object.freeze({ etag: true }), @@ -665,6 +822,16 @@ export function withFsConditionalMutation(base: FsAdapter): FsStorageAdapter { replace: true, }), physicalKey: Object.freeze({ maxBytes: 4096 }), + async copy(from, to, options) { + return serializeFilesMutationPair(from, to, options, () => + copy(from, to, options), + ); + }, + async delete(key, options) { + return serializeFilesMutation(key, options, () => + deleteObject(key, options), + ); + }, async download( key: string, options?: DownloadOptions, @@ -676,6 +843,24 @@ export function withFsConditionalMutation(base: FsAdapter): FsStorageAdapter { key: string, options: StorageConditionalReadOptions, ): Promise { + if ( + typeof options !== 'object' || + options === null || + typeof options.condition !== 'object' || + options.condition === null || + (options.condition.etag === undefined && + options.condition.version === undefined) + ) { + throw new StorageError( + 'A conditional filesystem read requires an ETag or version.', + { + code: StorageErrorCode.INVALID_ARGUMENT, + key, + operation: 'download', + permanent: true, + }, + ); + } if (options.condition.etag !== undefined) { assertEtag(options.condition.etag, 'condition.etag', key, 'download'); } @@ -690,54 +875,75 @@ export function withFsConditionalMutation(base: FsAdapter): FsStorageAdapter { }, ); } - return serialize(key, async () => { - let handle: fsp.FileHandle | undefined; - try { - const runtime = runtimeOf(options); - const segments = strictSegments(key); - const bodyPath = await ensureParents( - root, - segments, - key, - false, - runtime, - ); - const sidecarPath = bodyPath + SIDECAR_SUFFIX; - if ( - (await regularFileOrMissing(bodyPath, key)) === undefined || - (await regularFileOrMissing(sidecarPath, key)) === undefined - ) { - notFound(key); - } - const sidecar = await readSidecar(sidecarPath, key); - if ( - options.condition.etag !== undefined && - sidecar.etag !== options.condition.etag - ) { - conflict(key); - } - assertActive(runtime, key); - handle = await fsp.open( - bodyPath, - fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW, - ); - const stat = await handle.stat(); - if (!stat.isFile() || stat.nlink !== 1) { - invalidFsPath(key, 'an object path is not a regular file'); - } - const start = options.range?.start ?? 0; - const requestedEnd = options.range?.end ?? Math.max(0, stat.size - 1); - const end = Math.min(requestedEnd, Math.max(0, stat.size - 1)); - const size = start >= stat.size ? 0 : Math.max(0, end - start + 1); - if (size === 0) { - await handle.close(); + const runtime = runtimeOf(options); + return serialize( + key, + async () => { + let handle: fsp.FileHandle | undefined; + try { + const segments = strictSegments(key); + const bodyPath = await ensureParents( + root, + segments, + key, + false, + runtime, + ); + const sidecarPath = bodyPath + SIDECAR_SUFFIX; + if ( + (await regularFileOrMissing(bodyPath, key)) === undefined || + (await regularFileOrMissing(sidecarPath, key)) === undefined + ) { + notFound(key); + } + const sidecar = await readSidecar(sidecarPath, key); + if ( + options.condition.etag !== undefined && + sidecar.etag !== options.condition.etag + ) { + conflict(key); + } + assertActive(runtime, key); + handle = await fsp.open( + bodyPath, + fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW, + ); + const stat = await handle.stat(); + if (!stat.isFile() || stat.nlink !== 1) { + invalidFsPath(key, 'an object path is not a regular file'); + } + const start = options.range?.start ?? 0; + const requestedEnd = + options.range?.end ?? Math.max(0, stat.size - 1); + const end = Math.min(requestedEnd, Math.max(0, stat.size - 1)); + const size = start >= stat.size ? 0 : Math.max(0, end - start + 1); + if (size === 0) { + await handle.close(); + handle = undefined; + return { + body: new ReadableStream({ + start(controller) { + controller.close(); + }, + }), + contentType: sidecar.contentType, + etag: sidecar.etag, + key, + lastModified: new Date(sidecar.lastModified), + ...(sidecar.metadata !== undefined && { + metadata: { ...sidecar.metadata }, + }), + name: path.basename(key), + size: 0, + }; + } + const nodeStream = handle.createReadStream({ + autoClose: true, + ...(options.range !== undefined && { end, start }), + }); handle = undefined; return { - body: new ReadableStream({ - start(controller) { - controller.close(); - }, - }), + body: Readable.toWeb(nodeStream) as ReadableStream, contentType: sidecar.contentType, etag: sidecar.etag, key, @@ -746,31 +952,15 @@ export function withFsConditionalMutation(base: FsAdapter): FsStorageAdapter { metadata: { ...sidecar.metadata }, }), name: path.basename(key), - size: 0, + size, }; + } catch (error) { + await handle?.close().catch(() => undefined); + throw storageFsError(error, key, 'download'); } - const nodeStream = handle.createReadStream({ - autoClose: true, - ...(options.range !== undefined && { end, start }), - }); - handle = undefined; - return { - body: Readable.toWeb(nodeStream) as ReadableStream, - contentType: sidecar.contentType, - etag: sidecar.etag, - key, - lastModified: new Date(sidecar.lastModified), - ...(sidecar.metadata !== undefined && { - metadata: { ...sidecar.metadata }, - }), - name: path.basename(key), - size, - }; - } catch (error) { - await handle?.close().catch(() => undefined); - throw storageFsError(error, key, 'download'); - } - }); + }, + runtime, + ); }, async exists(key: string, options?: OperationOptions): Promise { await assertSymlinkFreeReadPath(root, key); @@ -787,60 +977,100 @@ export function withFsConditionalMutation(base: FsAdapter): FsStorageAdapter { } return result; }, + async move(from, to, options) { + return serializeFilesMutationPair(from, to, options, async () => { + if (move !== undefined) { + return move(from, to, options); + } + await copy(from, to, options); + await deleteObject(from, options); + }); + }, async deleteConditional( key: string, options: StorageConditionalDeleteOptions, ): Promise { - assertEtag(options.condition.etag, 'condition.etag', key, 'delete'); - return serialize(key, async () => { - const runtime = runtimeOf(options); - try { - const segments = strictSegments(key); - const bodyPath = await ensureParents( - root, - segments, + if ( + typeof options !== 'object' || + options === null || + typeof options.condition !== 'object' || + options.condition === null + ) { + throw new StorageError( + 'A conditional filesystem delete requires an ETag.', + { + code: StorageErrorCode.INVALID_ARGUMENT, key, - false, - runtime, - ); - const sidecarPath = bodyPath + SIDECAR_SUFFIX; - if ( - (await regularFileOrMissing(bodyPath, key)) === undefined || - (await regularFileOrMissing(sidecarPath, key)) === undefined - ) { - notFound(key); - } - const sidecar = await readSidecar(sidecarPath, key); - if (sidecar.etag !== options.condition.etag) { - conflict(key); - } - - assertActive(runtime, key); - await ensureParents(root, segments, key, false, runtime); - if ( - (await regularFileOrMissing(bodyPath, key)) === undefined || - (await regularFileOrMissing(sidecarPath, key)) === undefined || - (await readSidecar(sidecarPath, key)).etag !== - options.condition.etag - ) { - conflict(key); - } - await fsp.unlink(bodyPath); + operation: 'delete', + permanent: true, + }, + ); + } + assertEtag(options.condition.etag, 'condition.etag', key, 'delete'); + const runtime = runtimeOf(options); + return serialize( + key, + async () => { + let applied = false; try { + const segments = strictSegments(key); + const bodyPath = await ensureParents( + root, + segments, + key, + false, + runtime, + ); + const sidecarPath = bodyPath + SIDECAR_SUFFIX; + if ( + (await regularFileOrMissing(bodyPath, key)) === undefined || + (await regularFileOrMissing(sidecarPath, key)) === undefined + ) { + notFound(key); + } + const sidecar = await readSidecar(sidecarPath, key); + if (sidecar.etag !== options.condition.etag) { + conflict(key); + } + + assertActive(runtime, key); + await ensureParents(root, segments, key, false, runtime); + if ( + (await regularFileOrMissing(bodyPath, key)) === undefined || + (await regularFileOrMissing(sidecarPath, key)) === undefined || + (await readSidecar(sidecarPath, key)).etag !== + options.condition.etag + ) { + conflict(key); + } + await fsp.unlink(bodyPath); + applied = true; await fsp.unlink(sidecarPath); } catch (error) { - throw storageFsError(error, key, 'delete'); + throw applied + ? appliedStorageFsError(error, key, 'delete') + : storageFsError(error, key, 'delete'); } - } catch (error) { - throw storageFsError(error, key, 'delete'); - } - }); + }, + runtime, + ); }, async promote( sourceKey: string, destinationKey: string, options: StoragePromotionOptions, ): Promise { + if (typeof options !== 'object' || options === null) { + throw new StorageError( + 'A conditional filesystem copy requires a predicate.', + { + code: StorageErrorCode.INVALID_ARGUMENT, + key: sourceKey, + operation: 'copy', + permanent: true, + }, + ); + } const destination = options.destination; if ( destination !== undefined && @@ -858,6 +1088,21 @@ export function withFsConditionalMutation(base: FsAdapter): FsStorageAdapter { }, ); } + if ( + options.sourceEtag === undefined && + options.sourceVersion === undefined && + destination === undefined + ) { + throw new StorageError( + 'A conditional filesystem copy requires a source or destination predicate.', + { + code: StorageErrorCode.INVALID_ARGUMENT, + key: sourceKey, + operation: 'copy', + permanent: true, + }, + ); + } if (options.sourceEtag !== undefined) { assertEtag(options.sourceEtag, 'sourceEtag', sourceKey, 'copy'); } @@ -880,224 +1125,411 @@ export function withFsConditionalMutation(base: FsAdapter): FsStorageAdapter { }, ); } - return serializePair(sourceKey, destinationKey, async () => { - let bodyTemp: string | undefined; - let sidecarTemp: string | undefined; - const runtime = runtimeOf(options); - try { - const sourceSegments = strictSegments(sourceKey); - const destinationSegments = strictSegments(destinationKey); - const sourcePath = await ensureParents( - root, - sourceSegments, - sourceKey, - false, - runtime, - ); - const sourceSidecarPath = sourcePath + SIDECAR_SUFFIX; - if ( - (await regularFileOrMissing(sourcePath, sourceKey)) === undefined || - (await regularFileOrMissing(sourceSidecarPath, sourceKey)) === - undefined - ) { - notFound(sourceKey); - } - const sourceSidecar = await readSidecar(sourceSidecarPath, sourceKey); - if ( - options.sourceEtag !== undefined && - sourceSidecar.etag !== options.sourceEtag - ) { - conflict(sourceKey); - } - - const destinationPath = await ensureParents( - root, - destinationSegments, - destinationKey, - true, - runtime, - ); - const destinationSidecarPath = destinationPath + SIDECAR_SUFFIX; - const assertDestination = async (): Promise => { - const body = await regularFileOrMissing( - destinationPath, - destinationKey, + const runtime = runtimeOf(options); + return serializePair( + sourceKey, + destinationKey, + async () => { + let applied = false; + let bodyTemp: string | undefined; + let sidecarTemp: string | undefined; + try { + const sourceSegments = strictSegments(sourceKey); + const destinationSegments = strictSegments(destinationKey); + const sourcePath = await ensureParents( + root, + sourceSegments, + sourceKey, + false, + runtime, ); - const sidecar = await regularFileOrMissing( - destinationSidecarPath, - destinationKey, + const sourceSidecarPath = sourcePath + SIDECAR_SUFFIX; + if ( + (await regularFileOrMissing(sourcePath, sourceKey)) === + undefined || + (await regularFileOrMissing(sourceSidecarPath, sourceKey)) === + undefined + ) { + notFound(sourceKey); + } + const sourceSidecar = await readSidecar( + sourceSidecarPath, + sourceKey, ); - if (options.destination?.type === 'create') { - if (body !== undefined || sidecar !== undefined) { - conflict(destinationKey); - } - return; + if ( + options.sourceEtag !== undefined && + sourceSidecar.etag !== options.sourceEtag + ) { + conflict(sourceKey); } - if (options.destination?.type === 'replace') { - if (body === undefined || sidecar === undefined) { - notFound(destinationKey); + + const destinationPath = await ensureParents( + root, + destinationSegments, + destinationKey, + true, + runtime, + ); + const destinationSidecarPath = destinationPath + SIDECAR_SUFFIX; + const assertDestination = async (): Promise => { + const body = await regularFileOrMissing( + destinationPath, + destinationKey, + ); + const sidecar = await regularFileOrMissing( + destinationSidecarPath, + destinationKey, + ); + if (options.destination?.type === 'create') { + if (body !== undefined || sidecar !== undefined) { + conflict(destinationKey); + } + return; } - if ( - (await readSidecar(destinationSidecarPath, destinationKey)) - .etag !== options.destination.etag - ) { - conflict(destinationKey); + if (options.destination?.type === 'replace') { + if (body === undefined || sidecar === undefined) { + notFound(destinationKey); + } + if ( + (await readSidecar(destinationSidecarPath, destinationKey)) + .etag !== options.destination.etag + ) { + conflict(destinationKey); + } } - } - }; - await assertDestination(); + }; + await assertDestination(); - const staged = await openTemp(path.dirname(destinationPath)); - await staged.handle.close(); - bodyTemp = staged.path; - await fsp.copyFile(sourcePath, bodyTemp); - const lastModified = Date.now(); - sidecarTemp = await stageSidecar(path.dirname(destinationPath), { - ...sourceSidecar, - lastModified, - }); + const staged = await openTemp(path.dirname(destinationPath)); + await staged.handle.close(); + bodyTemp = staged.path; + await fsp.copyFile(sourcePath, bodyTemp); + const lastModified = Date.now(); + sidecarTemp = await stageSidecar(path.dirname(destinationPath), { + ...sourceSidecar, + lastModified, + }); - assertActive(runtime, sourceKey); - if ( - (await regularFileOrMissing(sourcePath, sourceKey)) === undefined || - (await regularFileOrMissing(sourceSidecarPath, sourceKey)) === - undefined || - (options.sourceEtag !== undefined && - (await readSidecar(sourceSidecarPath, sourceKey)).etag !== - options.sourceEtag) - ) { - conflict(sourceKey); + assertActive(runtime, sourceKey); + if ( + (await regularFileOrMissing(sourcePath, sourceKey)) === + undefined || + (await regularFileOrMissing(sourceSidecarPath, sourceKey)) === + undefined || + (options.sourceEtag !== undefined && + (await readSidecar(sourceSidecarPath, sourceKey)).etag !== + options.sourceEtag) + ) { + conflict(sourceKey); + } + await assertDestination(); + await fsp.rename(bodyTemp, destinationPath); + applied = true; + bodyTemp = undefined; + await fsp.rename(sidecarTemp, destinationSidecarPath); + sidecarTemp = undefined; + } catch (error) { + throw applied + ? appliedStorageFsError(error, destinationKey, 'copy') + : storageFsError(error, sourceKey, 'copy'); + } finally { + await removeTemp(bodyTemp).catch(() => undefined); + await removeTemp(sidecarTemp).catch(() => undefined); } - await assertDestination(); - await fsp.rename(bodyTemp, destinationPath); - bodyTemp = undefined; - await fsp.rename(sidecarTemp, destinationSidecarPath); - sidecarTemp = undefined; - } catch (error) { - throw storageFsError(error, sourceKey, 'copy'); - } finally { - await removeTemp(bodyTemp).catch(() => undefined); - await removeTemp(sidecarTemp).catch(() => undefined); - } - }); + }, + runtime, + ); }, async uploadConditional( key: string, body: Body, options: StorageConditionalUploadOptions, ): Promise { + if ( + typeof options !== 'object' || + options === null || + typeof options.condition !== 'object' || + options.condition === null || + (options.condition.type !== 'create' && + options.condition.type !== 'replace') + ) { + throw new StorageError( + 'A conditional filesystem upload requires a create or replace predicate.', + { + code: StorageErrorCode.INVALID_ARGUMENT, + key, + operation: 'upload', + permanent: true, + }, + ); + } if (options.condition.type === 'replace') { assertEtag(options.condition.etag, 'condition.etag', key, 'upload'); } - return serialize(key, async () => { - let bodyTemp: string | undefined; - let sidecarTemp: string | undefined; - const runtime = runtimeOf(options); - try { - if (options.multipart !== undefined && options.multipart !== false) { - unsupportedUpload( + const runtime = runtimeOf(options); + return serialize( + key, + async () => { + let applied = false; + let appliedEtag: string | undefined; + let bodyTemp: string | undefined; + let sidecarTemp: string | undefined; + try { + if ( + options.multipart !== undefined && + options.multipart !== false + ) { + unsupportedUpload( + key, + 'Conditional filesystem uploads do not support multipart mode.', + ); + } + if (options.control !== undefined) { + unsupportedUpload( + key, + 'Conditional filesystem uploads do not support resumable control.', + ); + } + + const segments = strictSegments(key); + const bodyPath = await ensureParents( + root, + segments, key, - 'Conditional filesystem uploads do not support multipart mode.', + true, + runtime, ); - } - if (options.control !== undefined) { - unsupportedUpload( + const sidecarPath = bodyPath + SIDECAR_SUFFIX; + const existingBody = await regularFileOrMissing(bodyPath, key); + const existingSidecar = await regularFileOrMissing( + sidecarPath, key, - 'Conditional filesystem uploads do not support resumable control.', ); - } - - const segments = strictSegments(key); - const bodyPath = await ensureParents( - root, - segments, - key, - true, - runtime, - ); - const sidecarPath = bodyPath + SIDECAR_SUFFIX; - const existingBody = await regularFileOrMissing(bodyPath, key); - const existingSidecar = await regularFileOrMissing(sidecarPath, key); - if (options.condition.type === 'create') { - if (existingBody !== undefined || existingSidecar !== undefined) { - conflict(key); - } - } else { - if (existingBody === undefined || existingSidecar === undefined) { - notFound(key); + if (options.condition.type === 'create') { + if (existingBody !== undefined || existingSidecar !== undefined) { + conflict(key); + } + } else { + if (existingBody === undefined || existingSidecar === undefined) { + notFound(key); + } + if ( + (await readSidecar(sidecarPath, key)).etag !== + options.condition.etag + ) { + conflict(key); + } } - if ( + + const staged = await stageBody( + path.dirname(bodyPath), + key, + body, + options, + runtime, + ); + bodyTemp = staged.path; + const lastModified = Date.now(); + const contentType = defaultContentType(body, options.contentType); + sidecarTemp = await stageSidecar(path.dirname(bodyPath), { + contentType, + etag: staged.etag, + lastModified, + ...(options.cacheControl !== undefined && { + cacheControl: options.cacheControl, + }), + ...(options.metadata !== undefined && { + metadata: options.metadata, + }), + }); + + assertActive(runtime, key); + await ensureParents(root, segments, key, false, runtime); + const currentBody = await regularFileOrMissing(bodyPath, key); + const currentSidecar = await regularFileOrMissing(sidecarPath, key); + if (options.condition.type === 'create') { + if (currentBody !== undefined || currentSidecar !== undefined) { + conflict(key); + } + } else if ( + currentBody === undefined || + currentSidecar === undefined || (await readSidecar(sidecarPath, key)).etag !== - options.condition.etag + options.condition.etag ) { conflict(key); } - } - const staged = await stageBody( - path.dirname(bodyPath), - key, - body, - options, - runtime, - ); - bodyTemp = staged.path; - const lastModified = Date.now(); - const contentType = defaultContentType(body, options.contentType); - sidecarTemp = await stageSidecar(path.dirname(bodyPath), { - contentType, - etag: staged.etag, - lastModified, - ...(options.cacheControl !== undefined && { - cacheControl: options.cacheControl, - }), - ...(options.metadata !== undefined && { - metadata: options.metadata, - }), - }); - - assertActive(runtime, key); - await ensureParents(root, segments, key, false, runtime); - const currentBody = await regularFileOrMissing(bodyPath, key); - const currentSidecar = await regularFileOrMissing(sidecarPath, key); - if (options.condition.type === 'create') { - if (currentBody !== undefined || currentSidecar !== undefined) { - conflict(key); - } - } else if ( - currentBody === undefined || - currentSidecar === undefined || - (await readSidecar(sidecarPath, key)).etag !== - options.condition.etag - ) { - conflict(key); + await fsp.rename(bodyTemp, bodyPath); + applied = true; + appliedEtag = staged.etag; + bodyTemp = undefined; + await fsp.rename(sidecarTemp, sidecarPath); + sidecarTemp = undefined; + return { + contentType, + etag: staged.etag, + key, + lastModified: new Date(lastModified), + size: staged.size, + }; + } catch (error) { + throw applied + ? appliedStorageFsError(error, key, 'upload', appliedEtag) + : storageFsError(error, key, 'upload'); + } finally { + await removeTemp(bodyTemp).catch(() => undefined); + await removeTemp(sidecarTemp).catch(() => undefined); } - - await fsp.rename(bodyTemp, bodyPath); - bodyTemp = undefined; - await fsp.rename(sidecarTemp, sidecarPath); - sidecarTemp = undefined; - return { - contentType, - etag: staged.etag, - key, - lastModified: new Date(lastModified), - size: staged.size, - }; - } catch (error) { - throw storageFsError(error, key, 'upload'); - } finally { - await removeTemp(bodyTemp).catch(() => undefined); - await removeTemp(sidecarTemp).catch(() => undefined); - } - }); + }, + runtime, + ); + }, + async upload(key, body, options) { + return serializeFilesMutation(key, options, () => + upload(key, body, options), + ); }, } satisfies FilesSdkConditionalCopyAdapter & FilesSdkConditionalDeleteAdapter & FilesSdkConditionalReadAdapter & FilesSdkConditionalUploadAdapter & FilesSdkPhysicalKeyAdapter & - Pick); + Pick< + FsAdapter, + | 'copy' + | 'delete' + | 'download' + | 'exists' + | 'head' + | 'list' + | 'move' + | 'upload' + >); + + if (resumableUpload !== undefined) { + adapter.resumableUpload = (key, options) => { + const resumable = resumableUpload(key, options); + const complete = resumable.complete.bind(resumable); + resumable.complete = (parts) => + serialize(key, () => complete(parts)) as ReturnType; + return resumable; + }; + } + + const operationOptions = ( + options: OperationOptions | undefined, + ): StorageOperationOptions => ({ + ...(options?.signal !== undefined && { signal: options.signal }), + ...(options?.timeout !== undefined && { timeout: options.timeout }), + }); + const uploadOptions = ( + options: AdapterUploadOptions | undefined, + ): Omit => ({ + ...operationOptions(options), + ...(options?.cacheControl !== undefined && { + cacheControl: options.cacheControl, + }), + ...(options?.contentType !== undefined && { + contentType: options.contentType, + }), + ...(options?.metadata !== undefined && { + metadata: { ...options.metadata }, + }), + ...(options?.onProgress !== undefined && { + onProgress: options.onProgress, + }), + }); + const downloadOptions = ( + options: AdapterDownloadOptions | undefined, + ): Omit => ({ + ...operationOptions(options), + ...(options?.range !== undefined && { range: { ...options.range } }), + }); + const runConditional = async ( + operation: () => Promise, + ): Promise => { + try { + return await operation(); + } catch (error) { + throw mapStorageErrorToFilesSdkError(error); + } + }; + + const conditional = Object.freeze({ + copy: Object.freeze({ + atomicSourceDestination: true, + destinationCreate: true, + destinationReplace: true, + run: ( + sourceKey: string, + destinationKey: string, + condition: CopyCondition, + options?: OperationOptions, + ): Promise => + runConditional(() => + adapter.promote(sourceKey, destinationKey, { + ...operationOptions(options), + destination: condition.destination, + sourceEtag: condition.source.etag, + }), + ), + sourceEtag: true, + }), + create: ( + key: string, + body: Body, + options?: AdapterUploadOptions, + ): Promise => + runConditional(async () => + conditionalUploadResultOf( + await adapter.uploadConditional(key, body, { + ...uploadOptions(options), + condition: { type: 'create' }, + }), + ), + ), + delete: ( + key: string, + etag: string, + options?: OperationOptions, + ): Promise => + runConditional(() => + adapter.deleteConditional(key, { + ...operationOptions(options), + condition: { etag }, + }), + ), + exactRead: ( + key: string, + etag: string, + options?: AdapterDownloadOptions, + ): Promise => + runConditional(async () => + storedFileOfStorageObject( + await adapter.downloadConditional(key, { + ...downloadOptions(options), + condition: { etag }, + }), + ), + ), + replace: ( + key: string, + body: Body, + etag: string, + options?: AdapterUploadOptions, + ): Promise => + runConditional(async () => + conditionalUploadResultOf( + await adapter.uploadConditional(key, body, { + ...uploadOptions(options), + condition: { etag, type: 'replace' }, + }), + ), + ), + } satisfies AdapterConditionalOperations); + + return Object.assign(adapter, { conditional }); } /** diff --git a/src/files-sdk/provider/index.ts b/src/files-sdk/provider/index.ts index be7a6b8..f11e208 100644 --- a/src/files-sdk/provider/index.ts +++ b/src/files-sdk/provider/index.ts @@ -18,7 +18,10 @@ import { type FilesSdkStorageDriver, } from '../files-sdk.driver.js'; import type { S3Adapter, S3ProviderProfile } from '../s3/index.js'; -import { recordS3ConstructionMetadata } from '../s3/construction-metadata.js'; +import { + recordS3ConditionalRequestPermission, + recordS3ConstructionMetadata, +} from '../s3/construction-metadata.js'; /** Slug of a storage provider this package can build a driver for. */ export type StorageProviderName = ProviderSlug; @@ -110,9 +113,14 @@ async function resolveAdapter( config: StorageProviderConfig | undefined, s3ProviderProfile: S3ProviderProfile | undefined, ): Promise { + const loaderConfig = withVerifiedS3ConditionalOptIn( + provider, + config, + s3ProviderProfile, + ); let resolved; try { - resolved = await loadFiles({ ...config, provider }); + resolved = await loadFiles({ ...loaderConfig, provider }); } catch (error) { throw mapFilesSdkError(error); } @@ -137,6 +145,12 @@ async function resolveAdapter( ? configJsonPublicBaseUrl : undefined); const s3Adapter = adapter as Parameters[0]; + if (provider === 's3') { + recordS3ConditionalRequestPermission( + s3Adapter.raw, + config?.configJson?.conditional !== false, + ); + } recordS3ConstructionMetadata(s3Adapter.raw, { publicBaseUrlConfigured: provider !== 's3' || @@ -156,6 +170,33 @@ async function resolveAdapter( }); } +function withVerifiedS3ConditionalOptIn( + provider: StorageProviderName, + config: StorageProviderConfig | undefined, + s3ProviderProfile: S3ProviderProfile | undefined, +): StorageProviderConfig | undefined { + const configJsonEndpoint = config?.configJson?.endpoint; + const hasCustomEndpoint = + config?.endpoint !== undefined || configJsonEndpoint !== undefined; + if ( + provider !== 's3' || + s3ProviderProfile === undefined || + config === undefined || + !hasCustomEndpoint || + config.configJson?.conditional !== undefined + ) { + return config; + } + + // files-sdk requires this construction-time opt-in before it exposes native + // conditional primitives for a custom endpoint. The branded provider profile + // still narrows the exposed operations in withS3Capabilities below. + return { + ...config, + configJson: { ...config.configJson, conditional: true }, + }; +} + function unknownProvider(provider: string): StorageError { return new StorageError( `Unknown storage provider: ${JSON.stringify(provider)}. Known providers: ${PROVIDER_NAMES.join(', ')}.`, diff --git a/src/files-sdk/provider/provider.driver.spec.ts b/src/files-sdk/provider/provider.driver.spec.ts index a7eac24..628d50a 100644 --- a/src/files-sdk/provider/provider.driver.spec.ts +++ b/src/files-sdk/provider/provider.driver.spec.ts @@ -243,11 +243,14 @@ describe('createProviderStorageDriver', () => { }); it('forwards an explicit verified profile to a custom S3 endpoint', async () => { + const configJson = Object.freeze({ + endpoint: 'https://objects.example.test', + }); const driver = await createProviderStorageDriver({ config: { accessKeyId: 'test', bucket: 'artifacts', - configJson: { endpoint: 'https://objects.example.test' }, + configJson, region: 'us-east-1', secretAccessKey: 'test', }, @@ -269,6 +272,32 @@ describe('createProviderStorageDriver', () => { contentType: false, sizeRange: false, }); + expect(configJson).toEqual({ endpoint: 'https://objects.example.test' }); + }); + + it('preserves an explicit custom-endpoint conditional opt-out', async () => { + await expect( + createProviderStorageDriver({ + config: { + accessKeyId: 'test', + bucket: 'artifacts', + configJson: { + conditional: false, + endpoint: 'https://objects.example.test', + }, + region: 'us-east-1', + secretAccessKey: 'test', + }, + provider: 's3', + s3ProviderProfile: defineS3ProviderProfile({ + name: 'verified-but-conditionals-disabled', + physicalKey: { maxBytes: 512 }, + conditionalCreate: { resultEtag: true }, + }), + }), + ).rejects.toThrow( + /requires conditional request headers, but the adapter was not constructed with conditional requests enabled/u, + ); }); it('rejects an S3 profile for a different provider', async () => { diff --git a/src/files-sdk/s3/construction-metadata.ts b/src/files-sdk/s3/construction-metadata.ts index 8c7acff..605ff0f 100644 --- a/src/files-sdk/s3/construction-metadata.ts +++ b/src/files-sdk/s3/construction-metadata.ts @@ -7,6 +7,26 @@ const constructionMetadata = new WeakMap< object, Readonly >(); +const conditionalRequestPermission = new WeakMap(); + +export function recordS3ConditionalRequestPermission( + raw: object, + enabled: boolean, +): void { + const existing = conditionalRequestPermission.get(raw); + if (existing !== undefined && existing !== enabled) { + throw new TypeError( + 'S3 conditional-request construction permission cannot be changed.', + ); + } + conditionalRequestPermission.set(raw, enabled); +} + +export function getS3ConditionalRequestPermission( + raw: object, +): boolean | undefined { + return conditionalRequestPermission.get(raw); +} export function recordS3ConstructionMetadata( raw: object, diff --git a/src/files-sdk/s3/index.ts b/src/files-sdk/s3/index.ts index a9fc807..7cbe8b9 100644 --- a/src/files-sdk/s3/index.ts +++ b/src/files-sdk/s3/index.ts @@ -14,6 +14,14 @@ import { } from '@aws-sdk/s3-presigned-post'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { Readable } from 'node:stream'; +import { + createStoredFile, + type AdapterConditionalOperations, + type ConditionalUploadResult, + type CopyCondition, + type OperationOptions as FilesOperationOptions, + type StoredFile, +} from 'files-sdk'; import { mapS3Error, s3 as createFilesSdkS3Adapter, @@ -56,10 +64,13 @@ import { type FilesSdkSignedDownloadPolicyAdapter, type FilesSdkStorageDriver, mapFilesSdkError, + mapStorageErrorToFilesSdkError, } from '../files-sdk.driver.js'; import { getS3ConstructionMetadata, + getS3ConditionalRequestPermission, recordS3ConstructionMetadata, + recordS3ConditionalRequestPermission, } from './construction-metadata.js'; export interface S3StorageDriverOptions extends Omit< @@ -185,6 +196,11 @@ export function defineS3ProviderProfile( 'conditionalCopySource.version', profile.conditionalCopySource?.version, ], + [ + profile.conditionalCopySource?.requiresDestinationPredicate !== undefined, + 'conditionalCopySource.requiresDestinationPredicate', + profile.conditionalCopySource?.requiresDestinationPredicate, + ], [ profile.conditionalCopyDestination !== undefined, 'conditionalCopyDestination.create', @@ -200,6 +216,11 @@ export function defineS3ProviderProfile( 'conditionalCopyDestination.atomicWithSource', profile.conditionalCopyDestination?.atomicWithSource, ], + [ + profile.conditionalCopyDestination?.requiresSourcePredicate !== undefined, + 'conditionalCopyDestination.requiresSourcePredicate', + profile.conditionalCopyDestination?.requiresSourcePredicate, + ], [ profile.conditionalMultipartCompletion !== undefined, 'conditionalMultipartCompletion.create', @@ -434,6 +455,29 @@ function assertS3ProviderProfileContainedBy( ); } } + + if ( + profile.conditionalCopySource !== undefined && + (profile.conditionalCopySource.etag || + profile.conditionalCopySource.version) && + ceiling.conditionalCopySource?.requiresDestinationPredicate === true && + profile.conditionalCopySource.requiresDestinationPredicate !== true + ) { + throw new TypeError( + `S3 provider profile "${profile.name}" cannot remove ${ceiling.name} conditionalCopySource.requiresDestinationPredicate.`, + ); + } + if ( + profile.conditionalCopyDestination !== undefined && + (profile.conditionalCopyDestination.create || + profile.conditionalCopyDestination.replace) && + ceiling.conditionalCopyDestination?.requiresSourcePredicate === true && + profile.conditionalCopyDestination.requiresSourcePredicate !== true + ) { + throw new TypeError( + `S3 provider profile "${profile.name}" cannot remove ${ceiling.name} conditionalCopyDestination.requiresSourcePredicate.`, + ); + } } export const AWS_S3_PROVIDER_PROFILE = defineS3ProviderProfile({ @@ -482,6 +526,47 @@ export type S3StorageAdapter = S3StorageAdapterBase & FilesSdkConditionalUploadAdapter >; +function filesAttemptOptions( + options: Options | undefined, +): Omit | undefined { + if (options === undefined) return undefined; + // Files owns retry settlement. Its adapter primitives receive a single + // merged attempt signal and must not start a second retry loop. + const { retries: _retries, ...attempt } = options; + return attempt; +} + +async function throughFilesConditionalBoundary( + operationName: 'copy' | 'delete' | 'download' | 'upload', + operation: () => Promise, +): Promise { + try { + return await operation(); + } catch (error: unknown) { + throw mapStorageErrorToFilesSdkError( + mapS3ConditionalError(error, operationName), + ); + } +} + +function storedFileOfStorageObject(object: StorageObject): StoredFile { + return createStoredFile( + { + ...(object.etag !== undefined && { etag: object.etag }), + key: object.key, + ...(object.lastModified !== undefined && { + lastModified: object.lastModified.getTime(), + }), + ...(object.metadata !== undefined && { + metadata: { ...object.metadata }, + }), + size: object.size, + type: object.contentType, + }, + { factory: () => object.body, kind: 'stream' }, + ); +} + function copySource( bucket: string, key: string, @@ -516,8 +601,31 @@ function maxRetries(options: StorageOperationOptions): number { return Math.max(0, Math.floor(configured ?? 0)); } +function invalidS3Argument( + message: string, + operation: 'copy' | 'delete' | 'download' | 'upload', + key?: unknown, +): never { + throw new StorageError(message, { + code: StorageErrorCode.INVALID_ARGUMENT, + ...(typeof key === 'string' && { key }), + operation, + permanent: true, + }); +} + +function assertS3Key( + key: unknown, + label: string, + operation: 'copy' | 'delete' | 'download' | 'upload', +): asserts key is string { + if (typeof key !== 'string' || key.length === 0) { + invalidS3Argument(`${label} must be a non-empty string.`, operation, key); + } +} + function etagHeader( - etag: string, + etag: unknown, key: string, operation: 'copy' | 'delete' | 'download' | 'upload', label: string, @@ -534,6 +642,183 @@ function etagHeader( return header; } +function assertConditionalReadInput( + key: string, + options: StorageConditionalReadOptions, +): void { + const candidate = options as unknown; + if (typeof candidate !== 'object' || candidate === null) { + invalidS3Argument( + 'Conditional read options must be an object.', + 'download', + key, + ); + } + const condition = (candidate as { readonly condition?: unknown }).condition; + if (typeof condition !== 'object' || condition === null) { + invalidS3Argument( + 'Conditional read requires an ETag or version predicate.', + 'download', + key, + ); + } + const { etag, version } = condition as { + readonly etag?: unknown; + readonly version?: unknown; + }; + if (etag === undefined && version === undefined) { + invalidS3Argument( + 'Conditional read requires an ETag or version predicate.', + 'download', + key, + ); + } + if (etag !== undefined) { + etagHeader(etag, key, 'download', 'condition.etag'); + } + if ( + version !== undefined && + (typeof version !== 'string' || version.length === 0) + ) { + invalidS3Argument( + 'condition.version must be a non-empty string.', + 'download', + key, + ); + } + + const range = (candidate as { readonly range?: unknown }).range; + if (range === undefined) return; + if (typeof range !== 'object' || range === null) { + invalidS3Argument('range must be an object.', 'download', key); + } + const { end, start } = range as { + readonly end?: unknown; + readonly start?: unknown; + }; + if (!Number.isSafeInteger(start) || (start as number) < 0) { + invalidS3Argument( + 'range.start must be a non-negative safe integer.', + 'download', + key, + ); + } + if ( + end !== undefined && + (!Number.isSafeInteger(end) || (end as number) < (start as number)) + ) { + invalidS3Argument( + 'range.end must be a safe integer greater than or equal to range.start.', + 'download', + key, + ); + } +} + +function assertPromotionInput( + sourceKey: string, + destinationKey: string, + promotion: StoragePromotionOptions, +): void { + const candidate = promotion as unknown; + if (typeof candidate !== 'object' || candidate === null) { + invalidS3Argument( + 'Promotion options must be an object.', + 'copy', + sourceKey, + ); + } + const { destination, sourceEtag, sourceVersion } = candidate as { + readonly destination?: unknown; + readonly sourceEtag?: unknown; + readonly sourceVersion?: unknown; + }; + if (sourceEtag !== undefined) { + etagHeader(sourceEtag, sourceKey, 'copy', 'sourceEtag'); + } + if ( + sourceVersion !== undefined && + (typeof sourceVersion !== 'string' || sourceVersion.length === 0) + ) { + invalidS3Argument( + 'sourceVersion must be a non-empty string.', + 'copy', + sourceKey, + ); + } + if ( + destination !== undefined && + (typeof destination !== 'object' || + destination === null || + ((destination as { readonly type?: unknown }).type !== 'create' && + (destination as { readonly type?: unknown }).type !== 'replace')) + ) { + invalidS3Argument( + 'destination.type must be "create" or "replace".', + 'copy', + destinationKey, + ); + } + if ( + typeof destination === 'object' && + destination !== null && + (destination as { readonly type?: unknown }).type === 'replace' + ) { + etagHeader( + (destination as { readonly etag?: unknown }).etag, + destinationKey, + 'copy', + 'destination.etag', + ); + } + if ( + sourceEtag === undefined && + sourceVersion === undefined && + destination === undefined + ) { + invalidS3Argument( + 'promote requires a source or destination precondition.', + 'copy', + sourceKey, + ); + } +} + +function assertConditionalUploadInput( + key: string, + conditional: StorageConditionalUploadOptions, +): void { + const candidate = conditional as unknown; + if (typeof candidate !== 'object' || candidate === null) { + invalidS3Argument( + 'Conditional upload options must be an object.', + 'upload', + key, + ); + } + const condition = (candidate as { readonly condition?: unknown }).condition; + if ( + typeof condition !== 'object' || + condition === null || + ((condition as { readonly type?: unknown }).type !== 'create' && + (condition as { readonly type?: unknown }).type !== 'replace') + ) { + invalidS3Argument( + 'condition.type must be "create" or "replace".', + 'upload', + key, + ); + } + if ((condition as { readonly type: unknown }).type === 'replace') { + etagHeader( + (condition as { readonly etag?: unknown }).etag, + key, + 'upload', + 'condition.etag', + ); + } +} + function providerEtag( etag: unknown, key: string, @@ -570,6 +855,23 @@ function contentTypeOf( return 'application/octet-stream'; } +function assertBufferedFilesConditionalS3Body( + body: StorageBody, + key: string, +): void { + if (body instanceof ReadableStream || body instanceof Readable) { + throw new StorageError( + 'Conditional S3 uploads through Files require a buffered body.', + { + code: StorageErrorCode.PROVIDER, + key, + operation: 'upload', + permanent: true, + }, + ); + } +} + async function normalizeConditionalBody( body: StorageBody, onProgress: StorageConditionalUploadOptions['onProgress'], @@ -634,21 +936,34 @@ function s3ErrorIdentity(error: unknown): { code: string | undefined; status: number | undefined; } { - if (typeof error !== 'object' || error === null) { - return { code: undefined, status: undefined }; - } - const candidate = error as { - readonly name?: unknown; - readonly Code?: unknown; - readonly code?: unknown; - readonly $metadata?: { readonly httpStatusCode?: unknown }; - }; - const serviceCode = candidate.name ?? candidate.Code ?? candidate.code; - const status = candidate.$metadata?.httpStatusCode; - return { - code: typeof serviceCode === 'string' ? serviceCode : undefined, - status: typeof status === 'number' ? status : undefined, - }; + let current = error; + const seen = new Set(); + for (let depth = 0; depth < 8; depth += 1) { + if (typeof current !== 'object' || current === null || seen.has(current)) { + break; + } + seen.add(current); + const candidate = current as { + readonly name?: unknown; + readonly Code?: unknown; + readonly code?: unknown; + readonly cause?: unknown; + readonly $metadata?: { readonly httpStatusCode?: unknown }; + }; + const serviceCode = candidate.name ?? candidate.Code ?? candidate.code; + const status = candidate.$metadata?.httpStatusCode; + if ( + typeof status === 'number' || + (typeof serviceCode === 'string' && serviceCode !== 'FilesError') + ) { + return { + code: typeof serviceCode === 'string' ? serviceCode : undefined, + status: typeof status === 'number' ? status : undefined, + }; + } + current = candidate.cause; + } + return { code: undefined, status: undefined }; } function mapS3ConditionalError( @@ -664,23 +979,29 @@ function mapS3ConditionalError( identity.status === 412 || identity.code === 'PreconditionFailed'; const conditionalConflict = identity.status === 409 || identity.code === 'ConditionalRequestConflict'; + const mappedPrecondition = + provider.code === StorageErrorCode.CONFLICT && !conditionalConflict; const notFound = identity.status === 404 || identity.code === 'NoSuchKey' || identity.code === 'NoSuchUpload' || provider.code === StorageErrorCode.NOT_FOUND; const code = - precondition || conditionalConflict + precondition || mappedPrecondition || conditionalConflict ? StorageErrorCode.CONFLICT : notFound ? StorageErrorCode.NOT_FOUND : provider.code; return new StorageError(`Conditional S3 ${operation} failed.`, { aborted: provider.aborted, + applied: provider.applied, + ...(provider.appliedEtag !== undefined && { + appliedEtag: provider.appliedEtag, + }), code, operation, permanent: - precondition || notFound + precondition || mappedPrecondition || notFound ? true : conditionalConflict ? false @@ -689,6 +1010,26 @@ function mapS3ConditionalError( }); } +function s3InterruptionError( + options: StorageOperationOptions, + signal: AbortSignal | undefined, + operation: 'copy' | 'delete' | 'download' | 'upload', +): StorageError | undefined { + if (signal?.aborted !== true) return undefined; + const timedOut = options.signal?.aborted !== true; + return new StorageError( + timedOut + ? `Conditional S3 ${operation} timed out.` + : `Conditional S3 ${operation} was aborted.`, + { + aborted: !timedOut, + code: timedOut ? StorageErrorCode.TIMEOUT : StorageErrorCode.ABORTED, + operation, + timedOut, + }, + ); +} + async function withS3Retry( options: StorageOperationOptions, operationName: 'copy' | 'delete' | 'download' | 'upload', @@ -700,10 +1041,15 @@ async function withS3Retry( try { return await operation(signal); } catch (error) { + const interrupted = s3InterruptionError(options, signal, operationName); + if (interrupted !== undefined) throw interrupted; const mapped = mapS3ConditionalError(error, operationName); + const retryableCode = + mapped.code === StorageErrorCode.PROVIDER || + mapped.code === StorageErrorCode.CONFLICT; if ( attempt >= retries || - mapped.code !== StorageErrorCode.PROVIDER || + !retryableCode || mapped.aborted || mapped.permanent || signal?.aborted === true @@ -718,7 +1064,14 @@ async function withS3Retry( error: mapped, }) : Math.min(1000, 100 * 2 ** attempt); - await waitForRetry(delay, options.signal); + try { + await waitForRetry(delay, signal); + } catch (waitError) { + throw ( + s3InterruptionError(options, signal, operationName) ?? + mapS3ConditionalError(waitError, operationName) + ); + } } } } @@ -745,11 +1098,70 @@ async function waitForRetry( reject(signal?.reason); }; signal?.addEventListener('abort', abort, { once: true }); + if (signal?.aborted === true) abort(); }); } const S3_MIN_MULTIPART_PART_BYTES = 5 * 1024 * 1024; const S3_MAX_MULTIPART_PARTS = 10_000; +const S3_MULTIPART_CLEANUP_TIMEOUT_MS = 5_000; + +async function waitForS3Cleanup( + request: Promise, + signal: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + const cleanup = (): void => signal.removeEventListener('abort', abort); + const abort = (): void => { + cleanup(); + reject(signal.reason); + }; + signal.addEventListener('abort', abort, { once: true }); + void request.then( + (result) => { + cleanup(); + resolve(result); + }, + (error: unknown) => { + cleanup(); + reject(error); + }, + ); + if (signal.aborted) abort(); + }); +} + +async function abortMultipartUpload( + base: S3RequestAdapter, + key: string, + uploadId: string, + callerSignal: AbortSignal | undefined, +): Promise { + const cleanupController = new AbortController(); + const cleanupTimeout = setTimeout( + () => cleanupController.abort(), + S3_MULTIPART_CLEANUP_TIMEOUT_MS, + ); + const cleanupSignal = + callerSignal === undefined + ? cleanupController.signal + : AbortSignal.any([callerSignal, cleanupController.signal]); + try { + await waitForS3Cleanup( + base.raw.send( + new AbortMultipartUploadCommand({ + Bucket: base.bucket, + Key: key, + UploadId: uploadId, + }), + { abortSignal: cleanupSignal }, + ), + cleanupSignal, + ); + } finally { + clearTimeout(cleanupTimeout); + } +} function conditionalHeaders( condition: StorageConditionalUploadOptions['condition'], @@ -859,6 +1271,7 @@ function missingResultEtag(key: string): StorageError { return new StorageError( 'S3 committed a conditional upload without returning the ETag required to identify its result; reconcile the destination before retrying.', { + applied: true, code: StorageErrorCode.PROVIDER, key, operation: 'upload', @@ -867,6 +1280,40 @@ function missingResultEtag(key: string): StorageError { ); } +function conditionalResultEtag(etag: unknown, key: string): string { + try { + const normalized = providerEtag(etag, key, 'upload'); + if (normalized === undefined) throw missingResultEtag(key); + return normalized; + } catch (error) { + if (!isStorageError(error) || error.applied) throw error; + throw new StorageError(error.message, { + aborted: error.aborted, + applied: true, + code: error.code, + key, + operation: 'upload', + permanent: true, + timedOut: error.timedOut, + }); + } +} + +function filesConditionalUploadResultOf( + result: StorageUploadResult, +): ConditionalUploadResult { + const etag = conditionalResultEtag(result.etag, result.key); + return { + contentType: result.contentType, + etag, + key: result.key, + ...(result.lastModified !== undefined && { + lastModified: result.lastModified.getTime(), + }), + size: result.size, + }; +} + type S3RequestAdapter = Pick; async function uploadConditionalSingle( @@ -912,8 +1359,7 @@ async function uploadConditionalSingle( total: normalized.contentLength, }), }); - const etag = providerEtag(result.ETag, key, 'upload'); - if (etag === undefined) throw missingResultEtag(key); + const etag = conditionalResultEtag(result.ETag, key); return { contentType, etag, key, size }; } @@ -945,6 +1391,7 @@ async function uploadConditionalMultipart( S3_MIN_MULTIPART_PART_BYTES, configuredPartSize ?? S3_MIN_MULTIPART_PART_BYTES, ); + const cleanupSignal = operationSignal(conditional); const normalized = await normalizeConditionalBody(body, undefined); if (normalized.contentLength === 0) { return uploadConditionalSingle(base, key, new Uint8Array(), { @@ -1032,14 +1479,9 @@ async function uploadConditionalMultipart( }); } if (completedParts.length === 0) { - await base.raw.send( - new AbortMultipartUploadCommand({ - Bucket: base.bucket, - Key: key, - UploadId: uploadId, - }), - ); + const emptyUploadId = uploadId; uploadId = undefined; + await abortMultipartUpload(base, key, emptyUploadId, cleanupSignal); return uploadConditionalSingle(base, key, new Uint8Array(), { ...conditional, multipart: false, @@ -1058,31 +1500,219 @@ async function uploadConditionalMultipart( signal === undefined ? undefined : { abortSignal: signal }, ), ); - const etag = providerEtag(completed.ETag, key, 'upload'); - if (etag === undefined) throw missingResultEtag(key); + const etag = conditionalResultEtag(completed.ETag, key); uploadId = undefined; return { contentType, etag, key, size }; } catch (error) { if (uploadId !== undefined) { - await base.raw - .send( - new AbortMultipartUploadCommand({ - Bucket: base.bucket, - Key: key, - UploadId: uploadId, - }), - ) - .catch(() => undefined); + await abortMultipartUpload(base, key, uploadId, cleanupSignal).catch( + () => undefined, + ); } throw error; } } +function profileUsesConditionalHeaders(profile: S3ProviderProfile): boolean { + return ( + profile.conditionalCreate !== undefined || + profile.conditionalReplace !== undefined || + profile.conditionalDelete?.etag === true || + profile.conditionalRead?.etag === true || + profile.conditionalCopySource?.etag === true || + profile.conditionalCopyDestination?.create === true || + profile.conditionalCopyDestination?.replace === true || + profile.conditionalMultipartCompletion?.create === true || + profile.conditionalMultipartCompletion?.replace === true + ); +} + +function conditionalOperationsForProfile( + profile: S3ProviderProfile, + adapter: S3StorageAdapter, + upstream: AdapterConditionalOperations | undefined, +): AdapterConditionalOperations | undefined { + const conditional: AdapterConditionalOperations = {}; + const legacyUpload = adapter.uploadConditional; + const legacyRead = adapter.downloadConditional; + const legacyDelete = adapter.deleteConditional; + const legacyCopy = adapter.promote; + + if (profile.conditionalCreate?.resultEtag === true) { + if (legacyUpload !== undefined) { + conditional.create = (key, body, options) => + throughFilesConditionalBoundary('upload', async () => { + assertBufferedFilesConditionalS3Body(body, key); + return filesConditionalUploadResultOf( + await legacyUpload(key, body, { + ...filesAttemptOptions(options), + condition: { type: 'create' }, + }), + ); + }); + } + } + + if (profile.conditionalReplace?.resultEtag === true) { + if (legacyUpload !== undefined) { + conditional.replace = (key, body, etag, options) => + throughFilesConditionalBoundary('upload', async () => { + assertBufferedFilesConditionalS3Body(body, key); + return filesConditionalUploadResultOf( + await legacyUpload(key, body, { + ...filesAttemptOptions(options), + condition: { etag, type: 'replace' }, + }), + ); + }); + } + } + + if (profile.conditionalRead?.etag === true) { + const nativeExactRead = upstream?.exactRead; + if (nativeExactRead !== undefined) { + conditional.exactRead = (key, etag, options) => + throughFilesConditionalBoundary('download', () => + nativeExactRead.call(upstream, key, etag, options), + ); + } else if (legacyRead !== undefined) { + conditional.exactRead = (key, etag, options) => + throughFilesConditionalBoundary('download', async () => + storedFileOfStorageObject( + await legacyRead(key, { + ...filesAttemptOptions(options), + condition: { etag }, + }), + ), + ); + } + } + + if (profile.conditionalDelete?.etag === true) { + const nativeDelete = upstream?.delete; + if (nativeDelete !== undefined) { + conditional.delete = (key, etag, options) => + throughFilesConditionalBoundary('delete', () => + nativeDelete.call(upstream, key, etag, options), + ); + } else if (legacyDelete !== undefined) { + conditional.delete = (key, etag, options) => + throughFilesConditionalBoundary('delete', () => + legacyDelete(key, { + ...filesAttemptOptions(options), + condition: { etag }, + }), + ); + } + } + + const destinationCreate = + profile.conditionalCopyDestination?.atomicWithSource === true && + profile.conditionalCopyDestination.create === true; + const destinationReplace = + profile.conditionalCopyDestination?.atomicWithSource === true && + profile.conditionalCopyDestination.replace === true; + if ( + profile.conditionalCopySource?.etag === true && + (destinationCreate || destinationReplace) && + legacyCopy !== undefined + ) { + const nativeCopy = upstream?.copy; + conditional.copy = Object.freeze({ + atomicSourceDestination: true, + destinationCreate, + destinationReplace, + async run( + sourceKey: string, + destinationKey: string, + condition: CopyCondition, + options?: FilesOperationOptions, + ): Promise { + const destinationSupported = + condition.destination.type === 'create' + ? destinationCreate + : destinationReplace; + if (!destinationSupported) { + throw mapStorageErrorToFilesSdkError( + new StorageError( + `S3 provider profile "${profile.name}" does not support the requested conditional copy.`, + { + code: StorageErrorCode.NOT_SUPPORTED, + key: destinationKey, + operation: 'copy', + permanent: true, + }, + ), + ); + } + const nativeDestinationSupported = + condition.destination.type === 'create' + ? nativeCopy?.destinationCreate === true + : nativeCopy?.destinationReplace === true; + if ( + nativeCopy?.sourceEtag === true && + nativeCopy.atomicSourceDestination === true && + nativeDestinationSupported + ) { + return throughFilesConditionalBoundary('copy', () => + nativeCopy.run.call( + nativeCopy, + sourceKey, + destinationKey, + condition, + options, + ), + ); + } + return throughFilesConditionalBoundary('copy', () => + legacyCopy(sourceKey, destinationKey, { + ...filesAttemptOptions(options), + destination: condition.destination, + sourceEtag: condition.source.etag, + }), + ); + }, + sourceEtag: true, + }); + } + + return Object.keys(conditional).length === 0 + ? undefined + : Object.freeze(conditional); +} + const configuredS3Clients = new WeakSet(); +const withheldCustomConditionalOperations = new WeakMap< + S3Adapter['raw'], + AdapterConditionalOperations +>(); /** Constructs the upstream S3 adapter while retaining security-relevant metadata. */ export function s3(options: S3AdapterOptions): S3Adapter { - const adapter = createFilesSdkS3Adapter(options); + // A verified provider profile is applied only by withS3Capabilities, after + // construction. Opt the raw custom-endpoint client into upstream's wire + // header guard here, but hide its broad conditional surface until that + // profile has narrowed the exact operations below. + const implicitCustomConditional = + options.endpoint !== undefined && options.conditional === undefined; + const adapter = createFilesSdkS3Adapter( + implicitCustomConditional ? { ...options, conditional: true } : options, + ); + if (options.endpoint !== undefined && adapter.conditional !== undefined) { + if (options.conditional === true) { + withheldCustomConditionalOperations.set(adapter.raw, adapter.conditional); + } + if (!Reflect.deleteProperty(adapter, 'conditional')) { + adapter.raw.destroy(); + throw new TypeError( + 'The custom-endpoint S3 conditional surface could not be withheld before provider verification.', + ); + } + } + recordS3ConditionalRequestPermission( + adapter.raw, + options.conditional !== false, + ); markFilesSdkS3AdapterUndecorated(adapter); recordS3ConstructionMetadata(adapter.raw, { publicBaseUrlConfigured: options.publicBaseUrl !== undefined, @@ -1128,6 +1758,17 @@ export function withS3Capabilities( ? AWS_S3_PROVIDER_PROFILE : UNVERIFIED_S3_PROVIDER_PROFILE); assertVerifiedS3ProviderProfile(profile); + const upstreamConditional = + base.conditional ?? withheldCustomConditionalOperations.get(raw); + if ( + profileUsesConditionalHeaders(profile) && + upstreamConditional === undefined && + getS3ConditionalRequestPermission(raw) !== true + ) { + throw new TypeError( + `S3 provider profile "${profile.name}" requires conditional request headers, but the adapter was not constructed with conditional requests enabled.`, + ); + } if (!sdkHasCustomEndpoint) { assertS3ProviderProfileContainedBy(profile, AWS_S3_PROVIDER_PROFILE); } @@ -1282,32 +1923,28 @@ export function withS3Capabilities( destinationKey: string, promotion: StoragePromotionOptions, ): Promise { - const destination = promotion.destination; - if ( - destination !== undefined && - (typeof destination !== 'object' || - destination === null || - (destination.type !== 'create' && destination.type !== 'replace')) - ) { - throw new StorageError( - 'destination.type must be "create" or "replace".', - { - code: StorageErrorCode.INVALID_ARGUMENT, - key: destinationKey, - operation: 'promote', - permanent: true, - }, - ); - } + assertS3Key(sourceKey, 'source key', 'copy'); + assertS3Key(destinationKey, 'destination key', 'copy'); + assertPromotionInput(sourceKey, destinationKey, promotion); if ( (promotion.sourceEtag !== undefined && profile.conditionalCopySource?.etag !== true) || (promotion.sourceVersion !== undefined && profile.conditionalCopySource?.version !== true) || + ((promotion.sourceEtag !== undefined || + promotion.sourceVersion !== undefined) && + promotion.destination === undefined && + profile.conditionalCopySource?.requiresDestinationPredicate === + true) || (promotion.destination?.type === 'create' && profile.conditionalCopyDestination?.create !== true) || (promotion.destination?.type === 'replace' && profile.conditionalCopyDestination?.replace !== true) || + (promotion.destination !== undefined && + promotion.sourceEtag === undefined && + promotion.sourceVersion === undefined && + profile.conditionalCopyDestination?.requiresSourcePredicate === + true) || (promotion.destination !== undefined && (promotion.sourceEtag !== undefined || promotion.sourceVersion !== undefined) && @@ -1368,6 +2005,8 @@ export function withS3Capabilities( key: string, options: StorageConditionalReadOptions, ): Promise { + assertS3Key(key, 'key', 'download'); + assertConditionalReadInput(key, options); if ( (options.condition.etag !== undefined && profile.conditionalRead?.etag !== true) || @@ -1424,13 +2063,15 @@ export function withS3Capabilities( } satisfies FilesSdkConditionalReadAdapter); } - if (profile.conditionalDelete !== undefined) { + if (profile.conditionalDelete?.etag === true) { Object.assign(adapter, { conditionalDelete: profile.conditionalDelete, async deleteConditional( key: string, options: StorageConditionalDeleteOptions, ): Promise { + assertS3Key(key, 'key', 'delete'); + etagHeader(options?.condition?.etag, key, 'delete', 'condition.etag'); await withS3Retry(options, 'delete', async (signal) => { await raw.send( new DeleteObjectCommand({ @@ -1469,14 +2110,8 @@ export function withS3Capabilities( body: StorageBody, conditional: StorageConditionalUploadOptions, ): Promise { - if (conditional.condition.type === 'replace') { - etagHeader( - conditional.condition.etag, - key, - 'upload', - 'condition.etag', - ); - } + assertS3Key(key, 'key', 'upload'); + assertConditionalUploadInput(key, conditional); const supported = conditional.condition.type === 'create' ? profile.conditionalCreate !== undefined @@ -1518,6 +2153,14 @@ export function withS3Capabilities( } satisfies FilesSdkConditionalUploadAdapter); } + Object.assign(adapter, { + conditional: conditionalOperationsForProfile( + profile, + adapter, + upstreamConditional, + ), + }); + Object.freeze(adapter); markFilesSdkS3RawClientProvenance(raw, adapter, provenance); configuredS3Clients.add(raw); diff --git a/src/files-sdk/s3/s3.driver.spec.ts b/src/files-sdk/s3/s3.driver.spec.ts index b241050..1062db8 100644 --- a/src/files-sdk/s3/s3.driver.spec.ts +++ b/src/files-sdk/s3/s3.driver.spec.ts @@ -1,4 +1,5 @@ import { + AbortMultipartUploadCommand, CompleteMultipartUploadCommand, CopyObjectCommand, CreateMultipartUploadCommand, @@ -20,8 +21,8 @@ import { s3 as upstreamS3 } from 'files-sdk/s3'; import { StorageClient } from '../../storage.client.js'; import { isStorageError, + StorageError, StorageErrorCode, - type StorageError, } from '../../storage.error.js'; import { createFilesSdkDriver } from '../files-sdk.driver.js'; import { @@ -196,6 +197,285 @@ describe('createS3StorageDriver', () => { } }); + it('publishes a frozen Files conditional surface narrowed to the provider profile', () => { + const base = s3(adapter); + const narrow = defineS3ProviderProfile({ + name: 'files-exact-read-only', + physicalKey: { maxBytes: 1024 }, + conditionalRead: { etag: true, version: false }, + }); + try { + const decorated = withS3Capabilities(base, { + providerProfile: narrow, + }); + + expect(Object.keys(decorated.conditional ?? {})).toEqual(['exactRead']); + expect(Object.isFrozen(decorated.conditional)).toBe(true); + expect(decorated.conditional?.create).toBeUndefined(); + expect(decorated.conditional?.copy).toBeUndefined(); + expect(decorated.conditional?.delete).toBeUndefined(); + expect(decorated.conditional?.replace).toBeUndefined(); + } finally { + base.raw.destroy(); + } + }); + + it('does not expose a Files conditional upload without guaranteed result ETags', () => { + const base = s3(adapter); + try { + const decorated = withS3Capabilities(base, { + providerProfile: defineS3ProviderProfile({ + name: 'conditional-create-without-result-etag', + physicalKey: { maxBytes: 1024 }, + conditionalCreate: { resultEtag: false }, + }), + }); + + expect(decorated.conditionalCreate).toEqual({ resultEtag: false }); + expect(decorated.conditional).toBeUndefined(); + } finally { + base.raw.destroy(); + } + }); + + it('does not expose direct conditional delete when the profile disables ETag deletion', () => { + const send = vi.spyOn(S3Client.prototype, 'send'); + const base = s3(adapter); + try { + const decorated = withS3Capabilities(base, { + providerProfile: defineS3ProviderProfile({ + name: 'conditional-delete-disabled', + physicalKey: { maxBytes: 1024 }, + conditionalDelete: { etag: false }, + }), + }); + + expect(decorated.conditionalDelete).toBeUndefined(); + expect(decorated.deleteConditional).toBeUndefined(); + expect(decorated.conditional?.delete).toBeUndefined(); + expect(send).not.toHaveBeenCalled(); + } finally { + base.raw.destroy(); + send.mockRestore(); + } + }); + + it('bridges verified custom-endpoint conditionals through the Files error boundary', async () => { + const base = s3({ + ...adapter, + endpoint: 'https://verified.objects.example.test', + }); + const requests: Array<{ + headers: Record; + input: { IfMatch?: string; IfNoneMatch?: string; Key?: string }; + }> = []; + base.raw.middlewareStack.add( + () => async (arguments_) => { + const request = arguments_.request as { + readonly headers?: Record; + }; + const input = arguments_.input as { + IfMatch?: string; + IfNoneMatch?: string; + Key?: string; + }; + requests.push({ headers: request.headers ?? {}, input }); + if (input.IfMatch !== undefined) { + throw Object.assign(new Error('private precondition detail'), { + $metadata: { httpStatusCode: 412 }, + name: 'PreconditionFailed', + }); + } + return { + output: { + $metadata: {}, + ...(input.Key !== 'ambiguous.txt' && { ETag: '"created"' }), + }, + response: { headers: {}, statusCode: 200 }, + } as never; + }, + { + name: 'captureVerifiedConditionalRequest', + priority: 'high', + step: 'finalizeRequest', + }, + ); + try { + expect(base.conditional).toBeUndefined(); + const decorated = withS3Capabilities(base, { + providerProfile: CLOUDFLARE_R2_PROVIDER_PROFILE, + }); + + expect(Object.keys(decorated.conditional ?? {}).sort()).toEqual([ + 'create', + 'exactRead', + 'replace', + ]); + expect(decorated.conditional?.copy).toBeUndefined(); + expect(decorated.conditional?.delete).toBeUndefined(); + await expect( + decorated.conditional?.create?.( + 'stream.txt', + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1])); + controller.close(); + }, + }), + ), + ).rejects.toMatchObject({ + code: 'Provider', + name: 'FilesError', + permanent: true, + }); + expect(requests).toHaveLength(0); + await expect( + decorated.conditional?.create?.('created.txt', 'body'), + ).resolves.toMatchObject({ etag: 'created', key: 'created.txt' }); + await expect( + decorated.conditional?.replace?.('created.txt', 'changed', 'previous'), + ).rejects.toMatchObject({ + code: 'Conflict', + message: 'Storage provider operation conflicted with current state.', + name: 'FilesError', + permanent: true, + }); + await expect( + decorated.conditional?.create?.('ambiguous.txt', 'body'), + ).rejects.toMatchObject({ + applied: true, + code: 'Provider', + name: 'FilesError', + permanent: true, + }); + + expect(requests[0]?.input).toMatchObject({ + IfNoneMatch: '*', + Key: 'created.txt', + }); + expect(requests[0]?.headers).toHaveProperty('if-none-match', '*'); + expect(requests[1]?.input).toMatchObject({ + IfMatch: '"previous"', + Key: 'created.txt', + }); + expect(requests[1]?.headers).toHaveProperty('if-match', '"previous"'); + } finally { + base.raw.destroy(); + } + }); + + it('cannot widen an adapter that explicitly disables conditional requests', () => { + const base = s3({ + ...adapter, + conditional: false, + endpoint: 'https://disabled.objects.example.test', + }); + try { + expect(base.conditional).toBeUndefined(); + expect(() => + withS3Capabilities(base, { + providerProfile: CLOUDFLARE_R2_PROVIDER_PROFILE, + }), + ).toThrow(/not constructed with conditional requests enabled/u); + expect(base.conditional).toBeUndefined(); + } finally { + base.raw.destroy(); + } + }); + + it('cannot widen an upstream native adapter that disabled conditional requests', () => { + const base = upstreamS3({ ...adapter, conditional: false }); + try { + expect(base.conditional).toBeUndefined(); + expect(() => withS3Capabilities(base)).toThrow( + /not constructed with conditional requests enabled/u, + ); + expect(base.conditional).toBeUndefined(); + } finally { + base.raw.destroy(); + } + }); + + it('withholds an explicit custom-endpoint opt-in until a verified profile narrows it', () => { + const base = s3({ + ...adapter, + conditional: true, + endpoint: 'https://opted-in.objects.example.test', + }); + const narrow = defineS3ProviderProfile({ + name: 'opted-in-exact-read-only', + physicalKey: { maxBytes: 1024 }, + conditionalRead: { etag: true, version: false }, + }); + try { + expect(base.conditional).toBeUndefined(); + const decorated = withS3Capabilities(base, { + providerProfile: narrow, + }); + expect(Object.keys(decorated.conditional ?? {})).toEqual(['exactRead']); + } finally { + base.raw.destroy(); + } + }); + + it('exposes Files conditional copy only for paired predicates allowed by the profile', async () => { + const send = vi.spyOn(S3Client.prototype, 'send'); + const base = s3(adapter); + const createOnlyCopy = defineS3ProviderProfile({ + name: 'files-create-only-copy', + physicalKey: { maxBytes: 1024 }, + conditionalCopyDestination: { + atomicWithSource: true, + create: true, + replace: false, + requiresSourcePredicate: true, + }, + conditionalCopySource: { + etag: true, + requiresDestinationPredicate: true, + version: false, + }, + }); + try { + const decorated = withS3Capabilities(base, { + providerProfile: createOnlyCopy, + }); + + expect(decorated.conditional?.copy).toMatchObject({ + atomicSourceDestination: true, + destinationCreate: true, + destinationReplace: false, + sourceEtag: true, + }); + await expect( + decorated.conditional?.copy?.run('source.txt', 'destination.txt', { + destination: { etag: 'destination-etag', type: 'replace' }, + source: { etag: 'source-etag' }, + }), + ).rejects.toMatchObject({ code: 'Provider', permanent: true }); + await expect( + decorated.promote?.('source.txt', 'destination.txt', { + sourceEtag: 'source-etag', + }), + ).rejects.toMatchObject({ + code: StorageErrorCode.NOT_SUPPORTED, + permanent: true, + }); + await expect( + decorated.promote?.('source.txt', 'destination.txt', { + destination: { type: 'create' }, + }), + ).rejects.toMatchObject({ + code: StorageErrorCode.NOT_SUPPORTED, + permanent: true, + }); + expect(send).not.toHaveBeenCalled(); + } finally { + base.raw.destroy(); + send.mockRestore(); + } + }); + it('does not apply the native AWS key ceiling to an explicitly verified custom endpoint', () => { const custom = s3({ ...adapter, @@ -295,6 +575,7 @@ describe('createS3StorageDriver', () => { const unverified = withS3Capabilities(unverifiedCustom); expect(unverified.conditionalCreate).toBeUndefined(); expect(unverified.conditionalRead).toBeUndefined(); + expect(unverified.conditional).toBeUndefined(); } finally { unverifiedCustom.raw.destroy(); } @@ -726,6 +1007,67 @@ describe('createS3StorageDriver', () => { }); }); + it('bounds hanging multipart cleanup without caller limits and preserves the original error', async () => { + vi.useFakeTimers(); + const original = new StorageError('original multipart failure', { + code: StorageErrorCode.PROVIDER, + operation: 'upload', + permanent: true, + }); + let cleanupSignal: AbortSignal | undefined; + let signalCleanupStarted: (() => void) | undefined; + const cleanupStarted = new Promise((resolve) => { + signalCleanupStarted = resolve; + }); + const send = vi + .spyOn(S3Client.prototype, 'send') + .mockImplementation((async ( + command: unknown, + options?: { readonly abortSignal?: AbortSignal }, + ) => { + if (command instanceof CreateMultipartUploadCommand) { + return { UploadId: 'upload-to-abort' }; + } + if (command instanceof UploadPartCommand) { + throw original; + } + if (command instanceof AbortMultipartUploadCommand) { + cleanupSignal = options?.abortSignal; + signalCleanupStarted?.(); + await new Promise(() => undefined); + } + throw new Error('Unexpected S3 command.'); + }) as never); + const base = s3(adapter); + try { + const decorated = withS3Capabilities(base); + const failure = rejectedStorageError(() => + decorated.uploadConditional!('cleanup.bin', new Uint8Array([1, 2, 3]), { + condition: { type: 'create' }, + multipart: true, + }), + ); + + await cleanupStarted; + const cleanup = send.mock.calls[2]; + expect(cleanup?.[0]).toBeInstanceOf(AbortMultipartUploadCommand); + expect(cleanupSignal).toBe( + (cleanup?.[1] as { readonly abortSignal?: AbortSignal } | undefined) + ?.abortSignal, + ); + expect(cleanupSignal?.aborted).toBe(false); + + await vi.advanceTimersByTimeAsync(5_000); + const error = await failure; + expect(cleanupSignal?.aborted).toBe(true); + expect(error).toBe(original); + } finally { + vi.useRealTimers(); + base.raw.destroy(); + send.mockRestore(); + } + }); + it('fails closed when S3 omits or returns a malformed result ETag', async () => { const send = vi.spyOn(S3Client.prototype, 'send'); const client = new StorageClient( @@ -750,6 +1092,7 @@ describe('createS3StorageDriver', () => { condition: { type: 'create' }, }), ).rejects.toMatchObject({ + applied: true, code: StorageErrorCode.PROVIDER, permanent: true, }); @@ -1118,6 +1461,49 @@ describe('createS3StorageDriver', () => { expect(send).not.toHaveBeenCalled(); }); + it('validates direct conditional method discriminants and non-empty predicates before dispatch', async () => { + const send = vi.spyOn(S3Client.prototype, 'send'); + const base = s3(adapter); + try { + const decorated = withS3Capabilities(base); + const invalidOperations: Array<() => Promise> = [ + () => + decorated.downloadConditional!('read.txt', { + condition: {}, + } as never), + () => + decorated.downloadConditional!('read.txt', { + condition: { version: '' }, + }), + () => decorated.promote!('source.txt', 'destination.txt', {}), + () => + decorated.promote!('source.txt', 'destination.txt', { + sourceVersion: '', + }), + () => decorated.uploadConditional!('upload.txt', 'body', {} as never), + () => + decorated.uploadConditional!('upload.txt', 'body', { + condition: { type: 'invalid' }, + } as never), + () => + decorated.uploadConditional!('upload.txt', 'body', { + condition: { type: 'replace' }, + } as never), + ]; + + for (const operation of invalidOperations) { + await expect(operation()).rejects.toMatchObject({ + code: StorageErrorCode.INVALID_ARGUMENT, + permanent: true, + }); + } + expect(send).not.toHaveBeenCalled(); + } finally { + base.raw.destroy(); + send.mockRestore(); + } + }); + it('normalizes 412, 409, and 404 without retaining provider errors', async () => { const providerMessages = [ 'secret 412 provider body', @@ -1189,6 +1575,135 @@ describe('createS3StorageDriver', () => { } }); + it('keeps ConditionalRequestConflict retryable through the Files pipeline', async () => { + const send = vi + .spyOn(S3Client.prototype, 'send') + .mockRejectedValueOnce( + Object.assign(new Error('transient conditional race'), { + $metadata: { httpStatusCode: 409 }, + name: 'ConditionalRequestConflict', + }), + ) + .mockResolvedValueOnce({ ETag: '"after-retry"' } as never); + const client = new StorageClient( + 'objects', + createS3StorageDriver({ adapter }), + ); + + await expect( + client.uploadConditional('retry.txt', 'changed', { + condition: { etag: 'before', type: 'replace' }, + retries: { backoff: () => 0, max: 1 }, + }), + ).resolves.toMatchObject({ etag: 'after-retry', key: 'retry.txt' }); + expect(send).toHaveBeenCalledTimes(2); + }); + + it('retries ConditionalRequestConflict during a direct multipart completion', async () => { + const backoff = vi.fn(() => 0); + const send = vi + .spyOn(S3Client.prototype, 'send') + .mockResolvedValueOnce({ UploadId: 'retry-upload' } as never) + .mockResolvedValueOnce({ ETag: '"part-1"' } as never) + .mockRejectedValueOnce( + Object.assign(new Error('transient multipart race'), { + $metadata: { httpStatusCode: 409 }, + name: 'ConditionalRequestConflict', + }), + ) + .mockResolvedValueOnce({ ETag: '"after-retry"' } as never); + const base = s3(adapter); + try { + const decorated = withS3Capabilities(base); + await expect( + decorated.uploadConditional!( + 'retry-multipart.bin', + new Uint8Array([1, 2, 3]), + { + condition: { type: 'create' }, + multipart: true, + retries: { backoff, max: 1 }, + }, + ), + ).resolves.toMatchObject({ + etag: 'after-retry', + key: 'retry-multipart.bin', + }); + + expect(send.mock.calls.map(([command]) => command.constructor)).toEqual([ + CreateMultipartUploadCommand, + UploadPartCommand, + CompleteMultipartUploadCommand, + CompleteMultipartUploadCommand, + ]); + expect(backoff).toHaveBeenCalledOnce(); + } finally { + base.raw.destroy(); + send.mockRestore(); + } + }); + + it('maps cancellation during direct S3 retry backoff to ABORTED', async () => { + const send = vi + .spyOn(S3Client.prototype, 'send') + .mockRejectedValue(new Error('transient provider failure') as never); + const controller = new AbortController(); + const base = s3(adapter); + try { + const decorated = withS3Capabilities(base); + const error = await rejectedStorageError(() => + decorated.uploadConditional!('retry-abort.txt', 'body', { + condition: { type: 'create' }, + retries: { + backoff: () => { + setTimeout(() => controller.abort('cancel retry'), 0); + return 10_000; + }, + max: 1, + }, + signal: controller.signal, + }), + ); + + expect(error).toMatchObject({ + aborted: true, + code: StorageErrorCode.ABORTED, + timedOut: false, + }); + expect(send).toHaveBeenCalledTimes(1); + } finally { + base.raw.destroy(); + send.mockRestore(); + } + }); + + it('maps timeout during direct S3 retry backoff to TIMEOUT', async () => { + const send = vi + .spyOn(S3Client.prototype, 'send') + .mockRejectedValue(new Error('transient provider failure') as never); + const base = s3(adapter); + try { + const decorated = withS3Capabilities(base); + const error = await rejectedStorageError(() => + decorated.uploadConditional!('retry-timeout.txt', 'body', { + condition: { type: 'create' }, + retries: { backoff: () => 10_000, max: 1 }, + timeout: 20, + }), + ); + + expect(error).toMatchObject({ + aborted: false, + code: StorageErrorCode.TIMEOUT, + timedOut: true, + }); + expect(send).toHaveBeenCalledTimes(1); + } finally { + base.raw.destroy(); + send.mockRestore(); + } + }); + it('validates dependent profile capabilities', () => { expect(Object.isFrozen(AWS_S3_PROVIDER_PROFILE)).toBe(true); const conservativeUpload = defineS3ProviderProfile({ @@ -1242,6 +1757,54 @@ describe('createS3StorageDriver', () => { }, }), ).toThrow(/enable at least one source-copy condition/u); + expect(() => + defineS3ProviderProfile({ + name: 'invalid-copy-dependency-flag', + physicalKey: { maxBytes: 1024 }, + conditionalCopySource: { + etag: true, + requiresDestinationPredicate: 'yes', + version: false, + } as never, + }), + ).toThrow( + /conditionalCopySource\.requiresDestinationPredicate must be a boolean/u, + ); + expect(() => + defineS3ProviderProfile({ + name: 'invalid-destination-dependency-flag', + physicalKey: { maxBytes: 1024 }, + conditionalCopyDestination: { + atomicWithSource: false, + create: true, + replace: false, + requiresSourcePredicate: 'yes', + } as never, + }), + ).toThrow( + /conditionalCopyDestination\.requiresSourcePredicate must be a boolean/u, + ); + const dependencyNarrowed = defineS3ProviderProfile({ + name: 'dependency-narrowed-native-profile', + physicalKey: { maxBytes: 1024 }, + conditionalCopyDestination: { + atomicWithSource: true, + create: true, + replace: false, + requiresSourcePredicate: true, + }, + conditionalCopySource: { + etag: true, + requiresDestinationPredicate: true, + version: false, + }, + }); + expect(dependencyNarrowed.conditionalCopySource).toMatchObject({ + requiresDestinationPredicate: true, + }); + expect(dependencyNarrowed.conditionalCopyDestination).toMatchObject({ + requiresSourcePredicate: true, + }); }); it('rejects reflected profile-brand forgeries for native and custom endpoints', () => { diff --git a/src/gateway/storage-gateway.controller.ts b/src/gateway/storage-gateway.controller.ts index e98c98d..e3238be 100644 --- a/src/gateway/storage-gateway.controller.ts +++ b/src/gateway/storage-gateway.controller.ts @@ -26,7 +26,7 @@ import { StorageErrorCode, isStorageError, } from '../storage.error.js'; -import { storageEtagHeader } from '../storage-etag.js'; +import { isCanonicalStorageEtag, storageEtagHeader } from '../storage-etag.js'; import { StorageService } from '../storage.service.js'; import type { StorageByteRange, @@ -405,6 +405,12 @@ function toHttpException(error: unknown): HttpException { error: { code: error.code, message: publicStorageMessages[error.code], + ...(error.applied === true && { applied: true }), + ...(error.applied === true && + error.appliedEtag !== undefined && + isCanonicalStorageEtag(error.appliedEtag) && { + appliedEtag: error.appliedEtag, + }), }, }, storageHttpStatus(error), diff --git a/src/storage.client.spec.ts b/src/storage.client.spec.ts index 1e24479..2988eb0 100644 --- a/src/storage.client.spec.ts +++ b/src/storage.client.spec.ts @@ -119,6 +119,51 @@ describe('StorageClient', () => { ); }); + it('enforces capability-declared copy predicate counterparts', async () => { + const driver = createMemoryStorageDriver(); + const promote = vi.fn(async () => undefined); + Object.defineProperty(driver, 'capabilities', { + value: { + ...driver.capabilities, + conditionalCopyDestination: { + atomicWithSource: true, + create: true, + replace: true, + requiresSourcePredicate: true, + }, + conditionalCopySource: { + etag: true, + requiresDestinationPredicate: true, + version: false, + }, + }, + }); + driver.promote = promote; + const client = new StorageClient('media', driver); + + expect(() => + client.promote('staging.bin', 'final.bin', { + sourceEtag: 'source-etag', + }), + ).toThrow( + expect.objectContaining({ code: StorageErrorCode.NOT_SUPPORTED }), + ); + expect(() => + client.promote('staging.bin', 'final.bin', { + destination: { type: 'create' }, + }), + ).toThrow( + expect.objectContaining({ code: StorageErrorCode.NOT_SUPPORTED }), + ); + expect(promote).not.toHaveBeenCalled(); + + await client.promote('staging.bin', 'final.bin', { + destination: { type: 'create' }, + sourceEtag: 'source-etag', + }); + expect(promote).toHaveBeenCalledOnce(); + }); + it('rejects unconditional promotion and unsupported drivers', async () => { const client = new StorageClient('media', createMemoryStorageDriver()); @@ -194,6 +239,111 @@ describe('StorageClient', () => { }); }); + it('marks conditional mutations applied when a Storage after-operation plugin fails', async () => { + const driver = createMemoryStorageDriver(); + const uploadConditional = vi.fn(async (key: string) => ({ + contentType: 'text/plain', + etag: 'committed-etag', + key, + size: 4, + })); + const deleteConditional = vi.fn(async () => undefined); + const promote = vi.fn(async () => undefined); + Object.defineProperty(driver, 'capabilities', { + value: { + ...driver.capabilities, + conditionalCopyDestination: { + atomicWithSource: true, + create: true, + replace: false, + }, + conditionalCopySource: { etag: true, version: false }, + conditionalCreate: { resultEtag: true }, + conditionalDelete: { etag: true }, + }, + }); + driver.uploadConditional = uploadConditional; + driver.deleteConditional = deleteConditional; + driver.promote = promote; + const observedErrors: unknown[] = []; + const client = new StorageClient('media', driver, [ + { + async afterOperation() { + throw new Error('private mandatory audit failure'); + }, + onError(_context, error) { + observedErrors.push(error); + }, + }, + ]); + + await expect( + client.uploadConditional('created.txt', 'body', { + condition: { type: 'create' }, + }), + ).rejects.toMatchObject({ + applied: true, + appliedEtag: 'committed-etag', + code: StorageErrorCode.PROVIDER, + }); + await expect( + client.deleteConditional('deleted.txt', { + condition: { etag: 'current-etag' }, + }), + ).rejects.toMatchObject({ + applied: true, + appliedEtag: undefined, + code: StorageErrorCode.PROVIDER, + }); + await expect( + client.promote('source.txt', 'destination.txt', { + destination: { type: 'create' }, + sourceEtag: 'source-etag', + }), + ).rejects.toMatchObject({ + applied: true, + appliedEtag: undefined, + code: StorageErrorCode.PROVIDER, + }); + + expect(uploadConditional).toHaveBeenCalledOnce(); + expect(deleteConditional).toHaveBeenCalledOnce(); + expect(promote).toHaveBeenCalledOnce(); + expect(observedErrors).toHaveLength(3); + expect(observedErrors).toEqual([ + expect.objectContaining({ applied: true }), + expect.objectContaining({ applied: true }), + expect.objectContaining({ applied: true }), + ]); + }); + + it('marks a resolved conditional write applied when its advertised ETag is missing', async () => { + const driver = createMemoryStorageDriver(); + Object.defineProperty(driver, 'capabilities', { + value: { + ...driver.capabilities, + conditionalCreate: { resultEtag: true }, + }, + }); + driver.uploadConditional = vi.fn(async (key: string) => ({ + contentType: 'text/plain', + key, + size: 4, + })); + const client = new StorageClient('media', driver); + + await expect( + client.uploadConditional('created.txt', 'body', { + condition: { type: 'create' }, + }), + ).rejects.toMatchObject({ + applied: true, + appliedEtag: undefined, + code: StorageErrorCode.PROVIDER, + permanent: true, + }); + }); + it('rejects non-canonical conditional ETags in every slot before calling a driver', () => { const driver = createMemoryStorageDriver(); const promote = vi.fn(); diff --git a/src/storage.client.ts b/src/storage.client.ts index 787ad0b..24802c5 100644 --- a/src/storage.client.ts +++ b/src/storage.client.ts @@ -43,6 +43,10 @@ export const DEFAULT_BUFFER_LIMIT = 10 * 1024 * 1024; type BulkOperationOptions = StorageBulkOptions & StorageOperationOptions; type DownloadManyOptions = StorageBulkOptions & StorageDownloadOptions; +interface AppliedMutationSettlement { + readonly appliedEtag?: string; +} + function assertKey(key: string, label = 'key'): void { if (typeof key !== 'string' || key.length === 0) { throw new StorageError(`${label} must be a non-empty string.`, { @@ -354,8 +358,28 @@ export class StorageClient { ); } - return this.#execute({ key, operation: 'upload', store: this.name }, () => - uploadConditional.call(this.#driver, key, body, options), + return this.#execute( + { key, operation: 'upload', store: this.name }, + () => uploadConditional.call(this.#driver, key, body, options), + (result) => { + if (capability.resultEtag && !isCanonicalStorageEtag(result.etag)) { + throw new StorageError( + `Store "${this.name}" committed a conditional upload without the advertised result ETag.`, + { + code: StorageErrorCode.PROVIDER, + key, + operation: 'upload', + permanent: true, + store: this.name, + }, + ); + } + return { + ...(isCanonicalStorageEtag(result.etag) && { + appliedEtag: result.etag, + }), + }; + }, ); } @@ -509,8 +533,10 @@ export class StorageClient { ); } - return this.#execute({ key, operation: 'delete', store: this.name }, () => - deleteConditional.call(this.#driver, key, options), + return this.#execute( + { key, operation: 'delete', store: this.name }, + () => deleteConditional.call(this.#driver, key, options), + () => ({}), ); } @@ -599,16 +625,24 @@ export class StorageClient { const destinationCapability = this.#driver.capabilities.conditionalCopyDestination; const promote = this.#driver.promote; + const hasSourcePredicate = + sourceEtag !== undefined || sourceVersion !== undefined; if ( promote === undefined || (sourceEtag !== undefined && sourceCapability?.etag !== true) || (sourceVersion !== undefined && sourceCapability?.version !== true) || + (hasSourcePredicate && + destination === undefined && + sourceCapability?.requiresDestinationPredicate === true) || (destination?.type === 'create' && destinationCapability?.create !== true) || (destination?.type === 'replace' && destinationCapability?.replace !== true) || (destination !== undefined && - (sourceEtag !== undefined || sourceVersion !== undefined) && + !hasSourcePredicate && + destinationCapability?.requiresSourcePredicate === true) || + (destination !== undefined && + hasSourcePredicate && destinationCapability?.atomicWithSource !== true) ) { throw new StorageError( @@ -631,6 +665,7 @@ export class StorageClient { to: destinationKey, }, () => promote.call(this.#driver, sourceKey, destinationKey, options), + () => ({}), ); } @@ -856,22 +891,49 @@ export class StorageClient { async #execute( context: StorageOperationContext, operation: () => Promise, + appliedMutation?: (result: Result) => AppliedMutationSettlement, ): Promise { + let appliedSettlement: AppliedMutationSettlement | undefined; try { for (const plugin of this.#plugins) { await plugin.beforeOperation?.(context); } const result = await operation(); + if (appliedMutation !== undefined) { + // Settle before describing the result so even an unexpected local + // result-validation failure cannot turn a committed mutation into a + // safe-to-retry error. + appliedSettlement = {}; + appliedSettlement = appliedMutation(result); + } for (const plugin of this.#plugins) { await plugin.afterOperation?.(context, result); } return result; } catch (error) { - const normalized = normalizeStorageError(error, { + let normalized = normalizeStorageError(error, { ...(context.key !== undefined && { key: context.key }), operation: context.operation, store: this.name, }); + if (appliedSettlement !== undefined) { + normalized = new StorageError(normalized.message, { + aborted: normalized.aborted, + applied: true, + ...(isCanonicalStorageEtag(appliedSettlement.appliedEtag) && { + appliedEtag: appliedSettlement.appliedEtag, + }), + cause: normalized.cause, + code: normalized.code, + ...(normalized.key !== undefined && { key: normalized.key }), + ...(normalized.operation !== undefined && { + operation: normalized.operation, + }), + permanent: normalized.permanent, + ...(normalized.store !== undefined && { store: normalized.store }), + timedOut: normalized.timedOut, + }); + } for (const plugin of this.#plugins) { try { await plugin.onError?.(context, normalized); diff --git a/src/storage.error.spec.ts b/src/storage.error.spec.ts index 210ddca..c21be52 100644 --- a/src/storage.error.spec.ts +++ b/src/storage.error.spec.ts @@ -18,11 +18,15 @@ describe('StorageError', () => { readonly aborted = false; readonly timedOut = false; readonly permanent = true; + readonly applied = true; + readonly appliedEtag = 'committed-etag'; } const error = new ForeignStorageError('missing'); expect(isStorageError(error)).toBe(true); expect(normalizeStorageError(error)).toBe(error); + expect(error.applied).toBe(true); + expect(error.appliedEtag).toBe('committed-etag'); }); it('recognizes the exact legacy structural shape without a brand', () => { @@ -40,6 +44,55 @@ describe('StorageError', () => { expect(isStorageError(error)).toBe(true); }); + it('defaults reconciliation metadata and preserves an applied ETag', () => { + const unapplied = new StorageError('not committed', { + code: StorageErrorCode.CONFLICT, + }); + const applied = new StorageError('commit acknowledgement failed', { + applied: true, + appliedEtag: 'committed-etag', + code: StorageErrorCode.PROVIDER, + }); + + expect(unapplied.applied).toBe(false); + expect(unapplied.appliedEtag).toBeUndefined(); + expect(applied.applied).toBe(true); + expect(applied.appliedEtag).toBe('committed-etag'); + }); + + it('only retains a canonical applied ETag with positive applied evidence', () => { + const unapplied = new StorageError('not committed', { + appliedEtag: 'unconfirmed-etag', + code: StorageErrorCode.PROVIDER, + }); + const malformed = new StorageError('bad provider metadata', { + applied: true, + appliedEtag: '"quoted-etag"', + code: StorageErrorCode.PROVIDER, + }); + + expect(unapplied.appliedEtag).toBeUndefined(); + expect(malformed.appliedEtag).toBeUndefined(); + }); + + it.each([ + { applied: 'true' }, + { appliedEtag: 42 }, + { appliedEtag: '"quoted-etag"' }, + { applied: false, appliedEtag: 'unconfirmed-etag' }, + ])('rejects malformed reconciliation metadata: %o', (metadata) => { + const error = Object.assign(new Error('foreign'), { + aborted: false, + code: StorageErrorCode.PROVIDER, + name: 'StorageError', + permanent: false, + timedOut: false, + ...metadata, + }); + + expect(isStorageError(error)).toBe(false); + }); + it('does not classify unrelated errors from a coincidental code or name', () => { expect( isStorageError( diff --git a/src/storage.error.ts b/src/storage.error.ts index 971afc9..bc93f69 100644 --- a/src/storage.error.ts +++ b/src/storage.error.ts @@ -1,3 +1,5 @@ +import { isCanonicalStorageEtag } from './storage-etag.js'; + export const StorageErrorCode = { NOT_FOUND: 'NOT_FOUND', UNAUTHORIZED: 'UNAUTHORIZED', @@ -28,6 +30,8 @@ export interface StorageErrorOptions { aborted?: boolean; timedOut?: boolean; permanent?: boolean; + applied?: boolean; + appliedEtag?: string; cause?: unknown; } @@ -40,6 +44,8 @@ export class StorageError extends Error { readonly aborted: boolean; readonly timedOut: boolean; readonly permanent: boolean; + readonly applied: boolean; + readonly appliedEtag?: string; override readonly cause?: unknown; constructor(message: string, options: StorageErrorOptions) { @@ -58,6 +64,14 @@ export class StorageError extends Error { this.aborted = options.aborted === true; this.timedOut = options.timedOut === true; this.permanent = options.permanent === true; + this.applied = options.applied === true; + if ( + this.applied && + options.appliedEtag !== undefined && + isCanonicalStorageEtag(options.appliedEtag) + ) { + this.appliedEtag = options.appliedEtag; + } this.cause = options.cause; } } @@ -74,6 +88,8 @@ export function isStorageError(error: unknown): error is StorageError { const candidate = error as Error & { readonly [STORAGE_ERROR_BRAND]?: unknown; readonly aborted?: unknown; + readonly applied?: unknown; + readonly appliedEtag?: unknown; readonly code?: unknown; readonly key?: unknown; readonly operation?: unknown; @@ -90,6 +106,11 @@ export function isStorageError(error: unknown): error is StorageError { typeof candidate.aborted === 'boolean' && typeof candidate.timedOut === 'boolean' && typeof candidate.permanent === 'boolean' && + (candidate.applied === undefined || + typeof candidate.applied === 'boolean') && + (candidate.appliedEtag === undefined || + (candidate.applied === true && + isCanonicalStorageEtag(candidate.appliedEtag))) && (candidate.store === undefined || typeof candidate.store === 'string') && (candidate.operation === undefined || typeof candidate.operation === 'string') && diff --git a/src/storage.types.ts b/src/storage.types.ts index 15659a0..c0fe159 100644 --- a/src/storage.types.ts +++ b/src/storage.types.ts @@ -226,6 +226,8 @@ export interface StorageConditionalCopySourceCapability { etag: boolean; /** Copy can select an immutable source version. */ version: boolean; + /** A source predicate can only be used together with a destination predicate. */ + requiresDestinationPredicate?: boolean; } export interface StorageConditionalCopyDestinationCapability { @@ -233,6 +235,8 @@ export interface StorageConditionalCopyDestinationCapability { create: boolean; /** Copy can require that the destination matches an opaque provider ETag. */ replace: boolean; + /** A destination predicate can only be used together with a source predicate. */ + requiresSourcePredicate?: boolean; /** Source and destination predicates share one provider linearization point. */ atomicWithSource: boolean; } diff --git a/src/testing/provider-conformance.spec.ts b/src/testing/provider-conformance.spec.ts index e60e3c4..d0afa4f 100644 --- a/src/testing/provider-conformance.spec.ts +++ b/src/testing/provider-conformance.spec.ts @@ -89,6 +89,46 @@ describe('createStorageProviderConformanceCases', () => { ]); }); + it('treats paired-only copy predicates as unsupported when exercised alone', () => { + const contracts = createStorageProviderConformanceCases({ + provider: 'paired-only-copy', + expected: { + conditionalCopyDestination: { + atomicWithSource: true, + create: true, + replace: true, + requiresSourcePredicate: true, + }, + conditionalCopySource: { + etag: true, + requiresDestinationPredicate: true, + version: false, + }, + physicalKey: { maxBytes: 1024 }, + }, + createFixture() { + throw new Error('case generation must not create a fixture'); + }, + }); + const names = contracts.map(({ name }) => name); + + expect(names).toContain( + 'fails closed when source-ETag copy requires a destination predicate', + ); + expect(names).toContain( + 'fails closed when create-only destination copy requires a source predicate', + ); + expect(names).toContain( + 'fails closed when destination replacement copy requires a source predicate', + ); + expect(names).toContain( + 'combines etag source and create destination copy predicates atomically', + ); + expect(names).toContain( + 'combines etag source and replace destination copy predicates atomically', + ); + }); + it('executes every positive and fail-closed versioned copy combination', async () => { const positive = createStorageProviderConformanceCases({ provider: 'fake-versioned-atomic', diff --git a/src/testing/provider-conformance.ts b/src/testing/provider-conformance.ts index d1a350a..c32cb67 100644 --- a/src/testing/provider-conformance.ts +++ b/src/testing/provider-conformance.ts @@ -689,18 +689,23 @@ function conditionalReadVersionCase( function conditionalCopySourceEtagCase( options: StorageProviderConformanceOptions, ): StorageProviderConformanceCase { - const supported = options.expected.conditionalCopySource?.etag === true; + const source = options.expected.conditionalCopySource; + const supported = source?.etag === true; + const supportedAlone = + supported && source.requiresDestinationPredicate !== true; return providerCase( options, - supported + supportedAlone ? 'copies only the requested source ETag' - : 'fails closed when source-ETag copy is unsupported', + : supported + ? 'fails closed when source-ETag copy requires a destination predicate' + : 'fails closed when source-ETag copy is unsupported', async (context) => { const source = context.key('copy-source-etag.txt'); const destination = context.key('copy-source-etag-result.txt'); const original = await seed(context, source, 'source-one'); const etag = await resultEtag(context.client, source, original); - if (!supported) { + if (!supportedAlone) { await expectStorageError( () => context.client.promote(source, destination, { sourceEtag: etag }), @@ -731,17 +736,22 @@ function conditionalCopySourceEtagCase( function conditionalCopySourceVersionCase( options: StorageProviderConformanceOptions, ): StorageProviderConformanceCase { - const supported = options.expected.conditionalCopySource?.version === true; + const source = options.expected.conditionalCopySource; + const supported = source?.version === true; + const supportedAlone = + supported && source.requiresDestinationPredicate !== true; return providerCase( options, - supported + supportedAlone ? 'copies an immutable source version' - : 'fails closed when source-version copy is unsupported', + : supported + ? 'fails closed when source-version copy requires a destination predicate' + : 'fails closed when source-version copy is unsupported', async (context) => { const source = context.key('copy-source-version.txt'); const destination = context.key('copy-source-version-result.txt'); await seed(context, source, 'source-version-one'); - if (!supported) { + if (!supportedAlone) { await expectStorageError( () => context.client.promote(source, destination, { @@ -770,18 +780,22 @@ function conditionalCopySourceVersionCase( function conditionalCopyDestinationCreateCase( options: StorageProviderConformanceOptions, ): StorageProviderConformanceCase { - const supported = - options.expected.conditionalCopyDestination?.create === true; + const destinationCapability = options.expected.conditionalCopyDestination; + const supported = destinationCapability?.create === true; + const supportedAlone = + supported && destinationCapability.requiresSourcePredicate !== true; return providerCase( options, - supported + supportedAlone ? 'enforces create-only copy at the destination' - : 'fails closed when create-only destination copy is unsupported', + : supported + ? 'fails closed when create-only destination copy requires a source predicate' + : 'fails closed when create-only destination copy is unsupported', async (context) => { const source = context.key('copy-destination-create-source.txt'); const destination = context.key('copy-destination-create-result.txt'); await seed(context, source, 'copy-created'); - if (!supported) { + if (!supportedAlone) { await expectStorageError( () => context.client.promote(source, destination, { @@ -813,20 +827,24 @@ function conditionalCopyDestinationCreateCase( function conditionalCopyDestinationReplaceCase( options: StorageProviderConformanceOptions, ): StorageProviderConformanceCase { - const supported = - options.expected.conditionalCopyDestination?.replace === true; + const destinationCapability = options.expected.conditionalCopyDestination; + const supported = destinationCapability?.replace === true; + const supportedAlone = + supported && destinationCapability.requiresSourcePredicate !== true; return providerCase( options, - supported + supportedAlone ? 'enforces ETag replacement at the copy destination' - : 'fails closed when destination replacement copy is unsupported', + : supported + ? 'fails closed when destination replacement copy requires a source predicate' + : 'fails closed when destination replacement copy is unsupported', async (context) => { const source = context.key('copy-destination-replace-source.txt'); const destination = context.key('copy-destination-replace-result.txt'); await seed(context, source, 'replacement'); const original = await seed(context, destination, 'destination-old'); const etag = await resultEtag(context.client, destination, original); - if (!supported) { + if (!supportedAlone) { await expectStorageError( () => context.client.promote(source, destination, { diff --git a/src/workspace/storage-workspace.error.ts b/src/workspace/storage-workspace.error.ts index 243ef0b..ec0ce54 100644 --- a/src/workspace/storage-workspace.error.ts +++ b/src/workspace/storage-workspace.error.ts @@ -17,6 +17,8 @@ export class StorageWorkspaceError extends StorageError { constructor( message: string, options: { + applied?: boolean; + appliedEtag?: string; code: StorageErrorCodeValue; operation?: string; path?: string; @@ -24,6 +26,10 @@ export class StorageWorkspaceError extends StorageError { }, ) { super(message, { + ...(options.applied !== undefined && { applied: options.applied }), + ...(options.appliedEtag !== undefined && { + appliedEtag: options.appliedEtag, + }), code: options.code, ...(options.path !== undefined && { key: options.path }), ...(options.operation !== undefined && { @@ -80,7 +86,13 @@ export function isStorageWorkspaceError( export function workspaceError( code: StorageErrorCodeValue, message: string, - options: { operation?: string; path?: string; permanent?: boolean } = {}, + options: { + applied?: boolean; + appliedEtag?: string; + operation?: string; + path?: string; + permanent?: boolean; + } = {}, ): StorageWorkspaceError { return new StorageWorkspaceError(message, { code, ...options }); } @@ -95,6 +107,8 @@ export function sanitizeWorkspaceError( const code = isStorageError(error) ? error.code : StorageErrorCode.PROVIDER; const permanent = isStorageError(error) ? error.permanent : false; + const applied = isStorageError(error) && error.applied; + const appliedEtag = isStorageError(error) ? error.appliedEtag : undefined; const logicalPath = options.path; const target = logicalPath === undefined ? '' : ` for "${logicalPath}"`; @@ -102,6 +116,8 @@ export function sanitizeWorkspaceError( code, `Workspace ${options.operation} failed${target}.`, { + applied, + ...(appliedEtag !== undefined && { appliedEtag }), operation: options.operation, ...(logicalPath !== undefined && { path: logicalPath }), permanent, diff --git a/src/workspace/storage-workspace.spec.ts b/src/workspace/storage-workspace.spec.ts index c7f3da7..1a93742 100644 --- a/src/workspace/storage-workspace.spec.ts +++ b/src/workspace/storage-workspace.spec.ts @@ -220,7 +220,7 @@ describe('StorageWorkspace', () => { }); }); - it('fails workspace writes closed when Files plugins cannot intercept conditional operations', async () => { + it('runs conditional workspace writes through Files body transforms', async () => { const transform = vi.fn(); const driver = createFsStorageDriver({ adapter: { root }, @@ -243,17 +243,52 @@ describe('StorageWorkspace', () => { await expect( workspace.writeFile('plaintext.txt', 'plaintext', { mode: 'create' }), - ).rejects.toMatchObject({ - code: StorageErrorCode.NOT_SUPPORTED, - permanent: true, + ).resolves.toMatchObject({ path: 'plaintext.txt' }); + expect(transform).toHaveBeenCalledOnce(); + expect(transform).toHaveBeenCalledWith('plaintext'); + expect(readFileSync(join(root, 'runs/run-1/plaintext.txt'), 'utf8')).toBe( + 'ciphertext', + ); + }); + + it('preserves applied metadata when a post-commit Files plugin fails', async () => { + const driver = createFsStorageDriver({ + adapter: { root }, + plugins: [ + { + name: 'failing-observer', + wrap: handlers({ + upload: async (operation, next) => { + await next(operation); + throw new Error('private observer detail'); + }, + }), + }, + ], + }); + const workspace = mountStorageWorkspace( + new StorageClient('applied-workspace', driver), + { permissions: ALL_PERMISSIONS, prefix: 'runs/run-1' }, + ); + + const error = await workspace + .writeFile('applied.txt', 'committed', { mode: 'create' }) + .catch((cause: unknown) => cause); + + expect(error).toMatchObject({ + applied: true, + appliedEtag: expect.any(String), + code: StorageErrorCode.PROVIDER, + message: 'Workspace create failed for "applied.txt".', + name: 'StorageWorkspaceError', }); - expect(transform).not.toHaveBeenCalled(); - await expect(driver.exists('runs/run-1/plaintext.txt')).resolves.toBe( - false, + expect(String(error)).not.toContain('private observer detail'); + expect(readFileSync(join(root, 'runs/run-1/applied.txt'), 'utf8')).toBe( + 'committed', ); }); - it('routes overwrite reads and writes through the Files encryption plugin', async () => { + it('routes conditional and overwrite writes through Files encryption', async () => { const driver = createFsStorageDriver({ adapter: { root }, plugins: [encryption(new Uint8Array(32).fill(0x5a))], @@ -261,33 +296,40 @@ describe('StorageWorkspace', () => { const workspace = mountStorageWorkspace( new StorageClient('encrypted-overwrite', driver), { - permissions: ['read', 'write', 'copy', 'delete'], + permissions: ['read', 'write', 'create', 'replace', 'copy', 'delete'], prefix: 'runs/run-1', }, ); - await workspace.writeFile('protected.txt', 'first plaintext', { - mode: 'overwrite', - }); + const created = await workspace.writeFile( + 'protected.txt', + 'first plaintext', + { mode: 'create' }, + ); await workspace.writeFile('protected.txt', 'second plaintext', { + etag: created.etag as string, + mode: 'replace', + }); + await workspace.writeFile('protected.txt', 'latest plaintext', { mode: 'overwrite', }); await expect(workspace.readText('protected.txt')).resolves.toMatchObject({ - text: 'second plaintext', + text: 'latest plaintext', }); const raw = readFileSync(join(root, 'runs/run-1/protected.txt')); expect(raw.includes(Buffer.from('first plaintext'))).toBe(false); expect(raw.includes(Buffer.from('second plaintext'))).toBe(false); + expect(raw.includes(Buffer.from('latest plaintext'))).toBe(false); await workspace.copyFile('protected.txt', 'copied.txt', { mode: 'overwrite', }); await expect(workspace.readText('copied.txt')).resolves.toMatchObject({ - text: 'second plaintext', + text: 'latest plaintext', }); const copiedRaw = readFileSync(join(root, 'runs/run-1/copied.txt')); - expect(copiedRaw.includes(Buffer.from('second plaintext'))).toBe(false); + expect(copiedRaw.includes(Buffer.from('latest plaintext'))).toBe(false); expect(copiedRaw.equals(raw)).toBe(false); await workspace.deleteFile('copied.txt', { mode: 'unconditional' }); @@ -1088,7 +1130,11 @@ describe('StorageWorkspace', () => { workspace.moveFile('source.txt', 'moved.txt', { etag: source.etag ?? '', }), - ).rejects.toMatchObject({ code: StorageErrorCode.CONFLICT }); + ).rejects.toMatchObject({ + applied: true, + appliedEtag: expect.any(String), + code: StorageErrorCode.CONFLICT, + }); await expect(workspace.stat('source.txt')).resolves.toBeDefined(); await expect(workspace.stat('moved.txt')).resolves.toBeDefined(); @@ -1125,7 +1171,11 @@ describe('StorageWorkspace', () => { etag: source.etag ?? '', signal: controller.signal, }), - ).rejects.toMatchObject({ code: StorageErrorCode.CONFLICT }); + ).rejects.toMatchObject({ + applied: true, + appliedEtag: expect.any(String), + code: StorageErrorCode.CONFLICT, + }); expect(controller.signal.aborted).toBe(true); await expect(workspace.stat('moved.txt')).resolves.toBeDefined(); }); @@ -1153,7 +1203,11 @@ describe('StorageWorkspace', () => { workspace.moveFile('source.txt', 'moved.txt', { etag: source.etag ?? '', }), - ).rejects.toMatchObject({ code: StorageErrorCode.CONFLICT }); + ).rejects.toMatchObject({ + applied: true, + appliedEtag: expect.any(String), + code: StorageErrorCode.CONFLICT, + }); await expect(workspace.stat('source.txt')).rejects.toMatchObject({ code: StorageErrorCode.NOT_FOUND, }); diff --git a/src/workspace/storage-workspace.ts b/src/workspace/storage-workspace.ts index 61d8365..5761bb5 100644 --- a/src/workspace/storage-workspace.ts +++ b/src/workspace/storage-workspace.ts @@ -958,6 +958,8 @@ class StorageWorkspaceImplementation implements StorageWorkspaceContract { StorageErrorCode.CONFLICT, `Workspace move could not confirm source deletion; destination "${destinationPath}" was retained. Inspect both paths before retrying.`, { + applied: true, + appliedEtag: copied.etag, operation: 'move', path: destinationPath, permanent: true, diff --git a/test/gateway.e2e-spec.ts b/test/gateway.e2e-spec.ts index bc82664..e520bdc 100644 --- a/test/gateway.e2e-spec.ts +++ b/test/gateway.e2e-spec.ts @@ -24,6 +24,7 @@ import { } from '../src/gateway/index.js'; import { StorageModule } from '../src/storage.module.js'; import type { StorageDriver } from '../src/storage.driver.js'; +import { StorageError, StorageErrorCode } from '../src/storage.error.js'; import { CLOUDFLARE_R2_PROVIDER_PROFILE, createS3StorageDriver, @@ -310,6 +311,37 @@ describe.each(['express', 'fastify'])( }); }); + it('returns bounded reconciliation metadata for applied failures', async () => { + const driver = createMemoryStorageDriver(); + driver.upload = async () => { + throw new StorageError('private post-commit provider detail', { + applied: true, + appliedEtag: 'committed-etag', + code: StorageErrorCode.PROVIDER, + }); + }; + const appliedApp = await createApp(adapterName, 1024, { driver }); + + try { + const response = await request(appliedApp.getHttpServer()) + .put('/storage/object') + .query({ key: 'applied.txt' }) + .set('content-type', 'application/octet-stream') + .send(Buffer.from('committed body')) + .expect(502); + + expect(response.body.error).toEqual({ + applied: true, + appliedEtag: 'committed-etag', + code: StorageErrorCode.PROVIDER, + message: 'Storage provider operation failed.', + }); + expect(response.text).not.toContain('private post-commit'); + } finally { + await appliedApp.close(); + } + }); + it('quotes canonical provider ETags once and rejects malformed output', async () => { const driver = createMemoryStorageDriver({ adapter: { initial: { 'scoped/etag.txt': 'etag body' } },