Skip to content

feat(storage): add removeMany for batch object deletion#102

Open
chandranilbakshi wants to merge 5 commits into
InsForge:mainfrom
chandranilbakshi:main
Open

feat(storage): add removeMany for batch object deletion#102
chandranilbakshi wants to merge 5 commits into
InsForge:mainfrom
chandranilbakshi:main

Conversation

@chandranilbakshi

@chandranilbakshi chandranilbakshi commented Jul 10, 2026

Copy link
Copy Markdown

Closes #58

Summary

Adds StorageBucket.removeMany() for deleting multiple objects in one call, and fixes the README's incorrect remove() usage.

Backs the new DELETE /api/storage/buckets/:bucketName/objects endpoint (body { keys: [...] }) from InsForge/InsForge#1627, which returns a per-key result of deleted | notFound | failed.

Changes

  • removeMany(paths) — splits keys into batches of 1000 as there's a request cap of 1000 keys and deletes them concurrently via Promise.allSettled, so there's no client-side cap on how many keys you pass. Mirrors the dashboard's deleteObjects implementation.
  • Types — added RemoveManyResult, RemoveManyResponse (raw HTTP body), and RemoveManyOutcome (aggregated result), re-exported from the package root.

Summary by cubic

Add StorageBucket.removeMany() to delete many objects in one call with auto-batching and per-key results. Also fix README remove() example, export result types, and align code with existing style.

  • New Features

    • Client for DELETE /api/storage/buckets/:bucketName/objects (body { keys: string[] }).
    • removeMany(paths) batches up to 1000 keys per request and runs batches concurrently.
    • Returns { data: { success: string[], failures: { key, error }[] }, error }.
    • Exports RemoveManyResult, RemoveManyResponse, and RemoveManyOutcome.
  • Refactors

    • Conformed implementation to the existing coding style.

Written for commit bb7da6a. Summary will update on new commits.

Review in cubic

Note

Add removeMany to StorageBucket for batch object deletion

  • Adds StorageBucket.removeMany(paths: string[]) in storage.ts that deletes any number of keys via client-side batching (max 1000 per request, concurrent requests).
  • Returns an aggregated { success, failures } outcome where each key resolves independently; a rejected batch marks all its keys as failures.
  • Top-level error is only set for unexpected exceptions, not per-key failures.
  • Updates README and SDK reference docs with new usage examples and type documentation.

Macroscope summarized bb7da6a.

Summary by CodeRabbit

  • New Features

    • Added support for deleting multiple storage files with removeMany().
    • Reports successfully deleted files and per-file failures, including missing files.
    • Automatically processes large deletion lists in batches.
  • Documentation

    • Updated storage examples and reference documentation for single- and multiple-file deletion.
    • Documented batch limits and response formats.
    • Exposed additional storage operation result types for SDK consumers.

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Changes

Storage batch deletion

Layer / File(s) Summary
Batch deletion contracts and exports
src/modules/storage.ts, src/index.ts
Defines batch-delete limits and result types, then re-exports the new storage types publicly.
Batched deletion execution and documentation
src/modules/storage.ts, README.md, SDK-REFERENCE.md
Adds concurrent, batched removeMany processing with per-key outcomes and documents single- and multi-file deletion usage.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: jwfing

Poem

I thumped out keys in batches neat,
Deleted files from burrow street.
Success hopped left, failures right,
A thousand keys per request in flight.
The README now shows the way—
Multi-file magic saves the day!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR fixes the README mismatch and adds batch deletion support via removeMany(), satisfying issue #58's intent.
Out of Scope Changes check ✅ Passed The code, exports, and docs changes all support the new batch-delete feature and stay within the linked issue scope.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding storage batch deletion via removeMany.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chandranilbakshi chandranilbakshi marked this pull request as ready for review July 10, 2026 13:11

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/modules/storage.ts (1)

529-593: 🚀 Performance & Scalability | 🔵 Trivial | 🏗️ Heavy lift

Consider bounding concurrency for large batch counts.

removeMany sends all batches concurrently via Promise.allSettled. For very large inputs (e.g., 100,000 keys → 100 simultaneous DELETEs), this could overwhelm the server or hit connection limits. A bounded concurrency pool (e.g., processing N batches at a time) would improve resilience without changing the public API.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/storage.ts` around lines 529 - 593, Bound concurrency in
removeMany instead of passing all batches directly to Promise.allSettled.
Process batches through a small worker pool or chunked groups with a fixed
concurrency limit, while preserving each batch’s result ordering so the existing
success and failures aggregation remains unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/modules/storage.ts`:
- Around line 565-580: Guard the batch response iteration in removeMany by
handling a missing or non-array result.value.results before calling forEach.
Treat it as an empty batch or record appropriate per-key failures while
preserving outcomes already aggregated from other successful batches, instead of
allowing a TypeError to trigger the top-level catch path.

---

Nitpick comments:
In `@src/modules/storage.ts`:
- Around line 529-593: Bound concurrency in removeMany instead of passing all
batches directly to Promise.allSettled. Process batches through a small worker
pool or chunked groups with a fixed concurrency limit, while preserving each
batch’s result ordering so the existing success and failures aggregation remains
unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5a8443c7-b0af-4a9c-a510-4937b9e49ca8

📥 Commits

Reviewing files that changed from the base of the PR and between d50cd6b and 622daa8.

📒 Files selected for processing (4)
  • README.md
  • SDK-REFERENCE.md
  • src/index.ts
  • src/modules/storage.ts

Comment thread src/modules/storage.ts
Comment on lines +565 to +580
result.value.results.forEach((deleteResult) => {
if (deleteResult.status === 'deleted') {
success.push(deleteResult.key);
return;
}
failures.push({
key: deleteResult.key,
error: new InsForgeError(
deleteResult.status === 'notFound'
? 'Object not found'
: (deleteResult.message ?? 'Failed to delete object'),
deleteResult.status === 'notFound' ? 404 : 500,
'STORAGE_ERROR'
),
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard against a missing results array in the batch response.

If the server returns a 2xx response without a results field (e.g., an empty body or a different schema version), result.value.results.forEach will throw a TypeError, causing the entire removeMany to fall into the catch block and return a top-level error — losing all per-key outcomes from other successful batches. Adding a defensive check preserves the aggregated result pattern.

🛡️ Proposed fix
        result.value.results?.forEach((deleteResult) => {
          if (deleteResult.status === 'deleted') {
            success.push(deleteResult.key);
            return;
          }
          failures.push({
            key: deleteResult.key,
            error: new InsForgeError(
              deleteResult.status === 'notFound'
                ? 'Object not found'
                : (deleteResult.message ?? 'Failed to delete object'),
              deleteResult.status === 'notFound' ? 404 : 500,
              'STORAGE_ERROR'
            ),
          });
        });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
result.value.results.forEach((deleteResult) => {
if (deleteResult.status === 'deleted') {
success.push(deleteResult.key);
return;
}
failures.push({
key: deleteResult.key,
error: new InsForgeError(
deleteResult.status === 'notFound'
? 'Object not found'
: (deleteResult.message ?? 'Failed to delete object'),
deleteResult.status === 'notFound' ? 404 : 500,
'STORAGE_ERROR'
),
});
});
result.value.results?.forEach((deleteResult) => {
if (deleteResult.status === 'deleted') {
success.push(deleteResult.key);
return;
}
failures.push({
key: deleteResult.key,
error: new InsForgeError(
deleteResult.status === 'notFound'
? 'Object not found'
: (deleteResult.message ?? 'Failed to delete object'),
deleteResult.status === 'notFound' ? 404 : 500,
'STORAGE_ERROR'
),
});
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/storage.ts` around lines 565 - 580, Guard the batch response
iteration in removeMany by handling a missing or non-array result.value.results
before calling forEach. Treat it as an empty batch or record appropriate per-key
failures while preserving outcomes already aggregated from other successful
batches, instead of allowing a TypeError to trigger the top-level catch path.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 4 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/modules/storage.ts">

<violation number="1" location="src/modules/storage.ts:540">
P2: Large deletes can fan out into an unbounded number of simultaneous DELETE requests, which can overwhelm browser connection pools or the storage API. Consider using a small concurrency limit while still batching keys at 1000 per request.</violation>

<violation number="2" location="src/modules/storage.ts:565">
P2: If the server returns a 2xx response without a `results` field (e.g., empty body or unexpected schema), `result.value.results.forEach(...)` will throw a `TypeError`. Because `settled.forEach` processes batches sequentially and this is inside a try/catch, the exception will short-circuit into the top-level `error` return — discarding per-key outcomes already collected from earlier successful batches.

Using optional chaining (`results?.forEach`) ensures a malformed batch response is silently skipped rather than crashing the entire aggregation.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/modules/storage.ts
batches.push(paths.slice(index, index + DELETE_OBJECTS_MAX_KEYS));
}

const settled = await Promise.allSettled(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Large deletes can fan out into an unbounded number of simultaneous DELETE requests, which can overwhelm browser connection pools or the storage API. Consider using a small concurrency limit while still batching keys at 1000 per request.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/modules/storage.ts, line 540:

<comment>Large deletes can fan out into an unbounded number of simultaneous DELETE requests, which can overwhelm browser connection pools or the storage API. Consider using a small concurrency limit while still batching keys at 1000 per request.</comment>

<file context>
@@ -483,6 +512,85 @@ export class StorageBucket {
+        batches.push(paths.slice(index, index + DELETE_OBJECTS_MAX_KEYS));
+      }
+
+      const settled = await Promise.allSettled(
+        batches.map((keys) =>
+          this.http.delete<RemoveManyResponse>(
</file context>

Comment thread src/modules/storage.ts
return;
}

result.value.results.forEach((deleteResult) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: If the server returns a 2xx response without a results field (e.g., empty body or unexpected schema), result.value.results.forEach(...) will throw a TypeError. Because settled.forEach processes batches sequentially and this is inside a try/catch, the exception will short-circuit into the top-level error return — discarding per-key outcomes already collected from earlier successful batches.

Using optional chaining (results?.forEach) ensures a malformed batch response is silently skipped rather than crashing the entire aggregation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/modules/storage.ts, line 565:

<comment>If the server returns a 2xx response without a `results` field (e.g., empty body or unexpected schema), `result.value.results.forEach(...)` will throw a `TypeError`. Because `settled.forEach` processes batches sequentially and this is inside a try/catch, the exception will short-circuit into the top-level `error` return — discarding per-key outcomes already collected from earlier successful batches.

Using optional chaining (`results?.forEach`) ensures a malformed batch response is silently skipped rather than crashing the entire aggregation.</comment>

<file context>
@@ -483,6 +512,85 @@ export class StorageBucket {
+          return;
+        }
+
+        result.value.results.forEach((deleteResult) => {
+          if (deleteResult.status === 'deleted') {
+            success.push(deleteResult.key);
</file context>

@Fermionic-Lyu Fermionic-Lyu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid, well-documented addition — but before merge I'd resolve one architectural issue (duplicated wire types) and one API-shape/naming issue, both of which point at a simpler implementation. The README remove('user-avatar.png') fix is correct.

Highlights (details inline):

  1. Import the wire types from @insforge/shared-schemas instead of hand-declaring them. The server PR (InsForge#1627) already added DeleteObjectResult, DeleteObjectsResponse, DELETE_OBJECTS_MAX_KEYS, and DELETE_OBJECT_FAILURE_MESSAGE to storage-api.schema.ts. This module already imports StorageFileSchema/ListObjectsResponseSchema from there, and the dashboard's deleteObjects (which this PR mirrors) imports these too. The local RemoveManyResult/RemoveManyResponse/DELETE_OBJECTS_MAX_KEYS are duplicates that will drift.

  2. Prefer overloading remove(path: string | string[]) over a new removeMany. This SDK mirrors Supabase Storage, where remove(paths: string[]) is the batch method — that's the convention users expect (and why the old README example was wrong). One method, one mental model, and the naming question disappears.

  3. Return shape doesn't match the SDK's own batch convention. createSignedUrls returns a flat per-item array ({ path, signedUrl|null, error: string|null }). This returns a lossy split { success: string[], failures: { key, error: Error }[] } that also renames pathkey and swaps error: string for an Error object. Returning the server's { results } directly is cleaner, mirrors the endpoint 1:1, and lets callers decide whether notFound is an error.

  4. No tests. createSignedUrls has a directly analogous unit test ("resolves each path independently; one failure does not fail the rest"). This method's branching (batch splitting at 1000, rejected-batch → all keys fail, per-key deleted/notFound/failed mapping, empty short-circuit) needs the same coverage in src/modules/__tests__/storage.test.ts before merge.

  5. Client-side batching + unbounded concurrency — the server schema enforces .max(1000) on purpose; batching circumvents it and fans out unbounded. See inline.

Comment thread src/modules/storage.ts
* Max object keys accepted per batch-delete request. Requests with more keys are
* split into multiple requests client-side. Mirrors the server-side limit.
*/
const DELETE_OBJECTS_MAX_KEYS = 1000;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These wire types are duplicates of @insforge/shared-schemas and should be imported, not redeclared. The server PR (InsForge#1627) added all of them to packages/shared-schemas/src/storage-api.schema.ts:

  • DELETE_OBJECTS_MAX_KEYS — this exact constant
  • DELETE_OBJECT_FAILURE_MESSAGE — the 'Failed to delete object' string you hardcode at line 575
  • DeleteObjectResult — identical to your RemoveManyResult ({ key, status: 'deleted'|'notFound'|'failed', message? })
  • DeleteObjectsResponse — identical to your RemoveManyResponse ({ results: [...] })

This module already imports StorageFileSchema/ListObjectsResponseSchema from @insforge/shared-schemas, and the dashboard's deleteObjects — which this PR mirrors — imports DELETE_OBJECTS_MAX_KEYS and DeleteObjectsResponse from there rather than redefining them. Bump the dep (currently ^1.1.58) to the version that ships these and import them; delete the local copies. Hand-maintained duplicates of a generated schema will silently drift when the server changes the shape.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These wire types are duplicates of @insforge/shared-schemas and should be imported, not redeclared. The server PR (InsForge#1627) added all of them to packages/shared-schemas/src/storage-api.schema.ts:

  • DELETE_OBJECTS_MAX_KEYS — this exact constant
  • DELETE_OBJECT_FAILURE_MESSAGE — the 'Failed to delete object' string you hardcode at line 575
  • DeleteObjectResult — identical to your RemoveManyResult ({ key, status: 'deleted'|'notFound'|'failed', message? })
  • DeleteObjectsResponse — identical to your RemoveManyResponse ({ results: [...] })

This module already imports StorageFileSchema/ListObjectsResponseSchema from @insforge/shared-schemas, and the dashboard's deleteObjects — which this PR mirrors — imports DELETE_OBJECTS_MAX_KEYS and DeleteObjectsResponse from there rather than redefining them. Bump the dep (currently ^1.1.58) to the version that ships these and import them; delete the local copies. Hand-maintained duplicates of a generated schema will silently drift when the server changes the shape.

The shared-schemas source currently contains these exports and is versioned as 1.2.0, but npm currently only has 1.1.59, which does not include them. First we need to publish the package ver 1.2.0 then I proceed with this change right?

Comment thread src/modules/storage.ts
* `success` holds the keys that were deleted; `failures` pairs each remaining
* key with the reason it wasn't (not found, delete failed, or a request error).
*/
export interface RemoveManyOutcome {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Result shape doesn't follow this SDK's own convention for batch operations. Compare createSignedUrls, the existing precedent:

Array<{ path: string; signedUrl: string | null; error: string | null }>

Three divergences from that (and from the server's { results }):

  1. Split arrays vs flat list. The server returns { results: [{ key, status, message }] } and createSignedUrls returns a flat array; this reshapes into two arrays and collapses the notFound vs failed distinction into a stringly-typed Error.
  2. key vs path. The public API otherwise uses path/paths (remove(path), createSignedUrls(paths)). Use path for consistency.
  3. Error object vs error: string | null. createSignedUrls exposes a message string, not an Error instance.

Cleanest option: return the server's per-key results directly, mirroring the endpoint 1:1 like every other method here:

Promise<StorageResponse<DeleteObjectsResponse>>  // { data: { results }, error }

Callers filter data.results by status and decide for themselves whether notFound counts as an error — rather than the SDK baking in notFound = failure. This also collapses three exported types down to the one imported from shared-schemas.

Comment thread src/modules/storage.ts
* @returns `{ data: { success, failures }, error }`. `error` is non-null only
* for an unexpected top-level failure; per-key problems surface in `failures`.
*/
async removeMany(paths: string[]): Promise<StorageResponse<RemoveManyOutcome>> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer overloading remove over a separate removeMany. This SDK is a near-exact clone of Supabase Storage, where remove(paths: string[]) is the batch method — that's the shape users coming from Supabase expect, and it's exactly the assumption that made the old README remove(['x']) example look right. An overload removes the naming debate entirely and gives one mental model:

remove(path: string): Promise<StorageResponse<{ message: string }>>;
remove(paths: string[]): Promise<StorageResponse<DeleteObjectsResponse>>;

(string keeps the existing single-object endpoint for backward compat; array hits the batch endpoint.)

If you do keep a separate method, removeMany is defensible (*Many is the Prisma/Mongoose convention) — but avoid deleteMany, which clashes with the remove verb this module already uses. Worth confirming: does the single-object DELETE .../objects/:key endpoint still need to exist, or can a one-element array go through the batch endpoint and let you drop it?

Comment thread src/modules/storage.ts
batches.push(paths.slice(index, index + DELETE_OBJECTS_MAX_KEYS));
}

const settled = await Promise.allSettled(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Client-side batching circumvents a deliberate server cap, and fans out unbounded. Two things:

  • The server's deleteObjectsRequestSchema enforces .max(1000, 'Cannot delete more than 1000 objects at once'). This batching intentionally works around that hard limit — a product decision that arguably isn't the SDK's to make unilaterally. Every other method here is 1 call = 1 request; a thin client would pass keys straight through and surface the server's 400 for >1000.
  • If batching stays, this launches unbounded concurrent requests — 100k keys → 100 parallel DELETEs, 1M → 1000 — which can exhaust the browser connection pool and hammer the backend. Bound it with a small concurrency pool (~5–10 in flight).

Comment thread src/modules/storage.ts
}

result.value.results.forEach((deleteResult) => {
if (deleteResult.status === 'deleted') {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor robustness: this assumes the body always has results. If a batch ever returns 204 (parseResponseundefined) or an unexpected shape, result.value.results.forEach throws inside this callback, the outer catch fires, and the entire operation collapses to a top-level error — discarding sibling batches that already succeeded. Guard with (result.value?.results ?? []), or treat a malformed batch like a rejected one. (Moot if you switch to returning { results } directly.)

Comment thread src/modules/storage.ts
});

return { data: { success, failures }, error: null };
} catch (error) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

error is null even when every key failed. Every other method in this module sets error on failure, so a caller following the pattern this SDK trains (if (error) …) will read a fully-failed batch as success. The pass-through { results } shape (see the RemoveManyOutcome comment) sidesteps this since each entry's status is self-describing; if you keep the aggregated shape, this asymmetry is worth calling out even more prominently in the docs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: StorageBucket.remove() only accepts a single string, but docs show array usage

2 participants