Skip to content

feat: add soft-delete lifecycle support - #28

Merged
Xe merged 13 commits into
mainfrom
Xe/soft-delete
Aug 11, 2026
Merged

Xe merged 13 commits into
mainfrom
Xe/soft-delete

Conversation

@Xe

@Xe Xe commented Jun 1, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • Add Go helpers covering the full Tigris soft-delete lifecycle to the storage and tigrisheaders packages (TIG-8441).
  • Complements the existing tigrisheaders.WithSoftDelete bucket-create option with listing, restore, force/permanent delete, and existing-bucket enable/disable.

Details

storage.Client gains:

Method Action
CreateBucketWithSoftDelete Create a bucket with soft delete (0 = default 7-day window)
ListSoftDeletedObjects List soft-deleted versions, surfacing Size, ETag, SoftDeleted, VersionID, LastModified (with pagination)
RestoreSoftDeletedObject Restore the latest or a specific soft-deleted version
RestoreBucket Restore a soft-deleted bucket
ForceDeleteBucket Force/soft-delete a bucket (soft vs permanent governed by bucket config)
PermanentlyDeleteObject Purge a specific soft-deleted version
SetBucketSoftDelete Enable/disable soft delete on an existing bucket

tigrisheaders gains WithForceDelete() (X-Tigris-Force-Delete: true).

Operations the S3 SDK cannot express (restore object/bucket, the enriched
versions listing, and the PATCH bucket config) go through a shared
SigV4-signed raw-HTTP path extracted from bundle.go into a new rawhttp.go
(doSignedRequest); BundleObjects now reuses it instead of duplicating the
signing logic. Method names (RestoreSoftDeletedObject, ForceDeleteBucket,
PermanentlyDeleteObject) are chosen to avoid shadowing the embedded
*s3.Client methods. Object keys are escaped via net/url so the signed
canonical URI matches the request that is sent.

Test plan

  • Code compiles with go build ./...
  • Code formatted with npm run format
  • go test ./... passes (httptest request-construction tests assert method/path/query/headers/SigV4; XML-decode and validation tests; credential-gated TestSoftDeleteLifecycle_integration skips without creds)
  • go vet ./... clean
  • Manual testing against a live Tigris endpoint

Refs: TIG-8441


Note

Medium Risk
Introduces destructive delete/restore APIs and changes signing/error behavior for bundle and raw-HTTP extensions; mistakes could cause data loss or auth failures on special object keys, though behavior is heavily tested.

Overview
Adds soft-delete support to storage.Client: create buckets with retention, list/restore soft-deleted objects and buckets, enable/disable on existing buckets, force-delete buckets, and permanently purge soft-deleted versions. S3-backed calls use tigrisheaders (WithSoftDelete, new WithForceDelete); list/restore/PATCH flows use SigV4 raw HTTP because the S3 SDK cannot express those endpoints.

rawhttp.go centralizes signing (doSignedRequest, DisableURIPathEscaping for object keys), APIError (smithy.APIError), and shared endpoint helpers. BundleObjects now uses this path instead of duplicated signing logic and returns typed HTTP errors.

README and examples document the lifecycle and error handling. Large httptest coverage plus optional live integration test.

Reviewed by Cursor Bugbot for commit 5faa633. Bugbot is set up for automated code reviews on this repo. Configure here.

Xe added 2 commits June 1, 2026 12:37
Add the WithSoftDelete option helper, which sets the X-Tigris-Soft-Delete
header on CreateBucket. Calling it with no arguments enables the default
7-day retention window; passing a single integer between 7 and 90 sets a
custom window in days.

Listing, restoring, and permanently deleting soft-deleted objects ride on
standard S3 versioning and need no Tigris-specific headers, so they are
left out of this package.

Assisted-by: Claude Opus 4.8 via Claude Code
Signed-off-by: Xe Iaso <xe@tigrisdata.com>
Add helpers spanning the full Tigris soft-delete lifecycle to the storage
and tigrisheaders packages, complementing the existing
tigrisheaders.WithSoftDelete bucket-create option.

storage.Client gains CreateBucketWithSoftDelete, ListSoftDeletedObjects,
RestoreSoftDeletedObject, RestoreBucket, ForceDeleteBucket,
PermanentlyDeleteObject, and SetBucketSoftDelete. tigrisheaders gains
WithForceDelete.

Operations the S3 SDK cannot express (restore object/bucket, enriched
versions listing, PATCH bucket config) go through a shared SigV4-signed
raw-HTTP path extracted from bundle.go into rawhttp.go; BundleObjects now
reuses it. Methods are named to avoid shadowing embedded *s3.Client
methods.

Ref: TIG-8441
Assisted-by: Opus 4.8 via Claude Code
Signed-off-by: Xe Iaso <xe@tigrisdata.com>
Copilot AI review requested due to automatic review settings June 1, 2026 17:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds Go helpers for Tigris's soft-delete lifecycle on top of the existing S3-compatible Client, plus a new tigrisheaders.WithForceDelete option. SDK-expressible operations go through tigrisheaders header injection on standard S3 calls, while Tigris-only operations (versions listing with enriched fields, restore object/bucket, PATCH bucket config) use a new shared SigV4-signed raw-HTTP path in rawhttp.go that BundleObjects is refactored to reuse.

Changes:

  • New softdelete.go adds CreateBucketWithSoftDelete, ListSoftDeletedObjects, RestoreSoftDeletedObject, RestoreBucket, ForceDeleteBucket, PermanentlyDeleteObject, SetBucketSoftDelete on storage.Client.
  • New rawhttp.go extracts the shared SigV4 signing/transport helper (doSignedRequest, tigrisHTTPClient, baseEndpoint) used by both bundle and soft-delete code paths; bundle.go is refactored to use it.
  • tigrisheaders gains WithSoftDelete([days]) and WithForceDelete(), plus tests and examples; example_test.go adds runnable examples for each new Client method.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
softdelete.go New file implementing the soft-delete lifecycle API on Client, plus URL/path helpers and httpError wrapping.
softdelete_test.go Request-construction httptest coverage for each new method plus a credential-gated live lifecycle integration test.
rawhttp.go Shared SigV4-signed HTTP transport (doSignedRequest), reusable HTTP client, and baseEndpoint helper.
bundle.go Refactored to delegate signing/transport to doSignedRequest; removes duplicated SigV4 plumbing and local sha256Hex/HTTP client.
tigrisheaders/tigrisheaders.go Adds WithSoftDelete(days...) and WithForceDelete() header options.
tigrisheaders/tigrisheaders_test.go Validates new header functions and adds varied-input tests for WithSoftDelete.
tigrisheaders/example_test.go Documents the new header options with runnable examples.
example_test.go Adds ExampleClient_* entries for each new soft-delete lifecycle method.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread softdelete.go
Comment thread softdelete.go Outdated
@greptile-apps

greptile-apps Bot commented Jun 1, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a full soft-delete lifecycle to the storage and tigrisheaders packages, covering bucket/object creation with soft delete, listing, restore, force delete, and permanent purge. A shared doSignedRequest helper in the new rawhttp.go replaces the duplicated SigV4 signing logic that was inline in bundle.go.

  • New softdelete.go: Seven Client methods (CreateBucketWithSoftDelete, ForceDeleteBucket, PermanentlyDeleteObject, ListSoftDeletedObjects, RestoreSoftDeletedObject, RestoreBucket, SetBucketSoftDelete) using a raw HTTP path for operations the S3 SDK cannot express; key escaping is handled correctly via url.Parse.
  • New rawhttp.go: doSignedRequest centralises SigV4 signing and the shared http.Client, with baseEndpoint() extracting the endpoint-resolution logic reused by BundleObjects.
  • tigrisheaders: Two new option functions (WithSoftDelete, WithForceDelete) with accompanying examples and tests.

Confidence Score: 3/5

Safe to merge after addressing the missing validation in PermanentlyDeleteObject; the rest of the change is well-tested and structurally sound.

PermanentlyDeleteObject is the one function in this PR designed specifically to purge a known version — a versionID is semantically required — yet it ships without any guard on bucket, key, or versionID. An empty versionID causes the AWS SDK to send ?versionId= (empty string) to the server, whose behavior is undefined and could silently affect an unintended object. All other new methods validate their required fields; the omission here stands out and carries real data-loss risk on the changed code path.

softdelete.go — specifically PermanentlyDeleteObject, which is the only new method without input validation.

Important Files Changed

Filename Overview
softdelete.go New file implementing the full soft-delete lifecycle API; well-structured overall but PermanentlyDeleteObject lacks input validation (bucket, key, versionID), unlike every other method added here.
rawhttp.go New shared SigV4 signing helper extracted cleanly from bundle.go; handles nil body, empty credentials, and region defaulting correctly.
bundle.go Refactored to delegate signing and HTTP transport to the new doSignedRequest helper; logic is equivalent to what was removed.
tigrisheaders/tigrisheaders.go Adds WithSoftDelete (variadic days) and WithForceDelete header helpers; implementation is correct and consistent with existing patterns.
softdelete_test.go Comprehensive httptest-based tests covering request construction, XML parsing, pagination, error handling, and an integration test gated behind credentials.
example_test.go New Example* functions for all seven new Client methods; clear and accurate.
tigrisheaders/tigrisheaders_test.go Adds targeted tests for WithSoftDelete (default and custom windows) and WithForceDelete; consistent with existing test style.
tigrisheaders/example_test.go New ExampleWithSoftDelete and ExampleWithForceDelete functions; straightforward and accurate.

Reviews (1): Last reviewed commit: "feat(storage): add soft-delete lifecycle..." | Re-trigger Greptile

Comment thread softdelete.go
Comment thread softdelete.go Outdated
…bject

Add exported ErrMissingBucket, ErrMissingKey, and ErrMissingVersionID so
callers can match argument-validation failures with errors.Is, and wrap
them with the calling method's name. PermanentlyDeleteObject now rejects
empty bucket, key, or versionID up front: an empty versionID would target
the latest version and create a new soft-delete marker rather than purging
a version, the opposite of the method's intent.

Also document that ForceDeleteBucket is unrecoverable so callers
understand the consequences before using it.

Assisted-by: Claude Opus 4.8 via Claude Code
Signed-off-by: Xe Iaso <xe@tigrisdata.com>

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 540dd7e. Configure here.

Comment thread softdelete.go
… purge

Address PR review feedback on the soft-delete helpers.

Reshape the new methods with non-SDK signatures to follow the AWS S3 SDK
(ctx, *Input) (*Output, error) shape: RestoreSoftDeletedObject, RestoreBucket,
and SetBucketSoftDelete now take dedicated *Input structs and return *Output
structs, and CreateBucketWithSoftDelete takes a *CreateBucketWithSoftDeleteInput
(embedding *s3.CreateBucketInput plus RetentionDays) instead of a positional
retentionDays argument. ForceDeleteBucket and PermanentlyDeleteObject keep their
s3.* types as they already match the SDK shape.

Also harden validation: PermanentlyDeleteObject now requires a non-empty
versionID, since an empty versionID would target the latest version and create a
new soft-delete marker rather than purging a version - the opposite of its
documented intent. Missing-argument checks now wrap exported sentinel errors
(ErrMissingBucket, ErrMissingKey, ErrMissingVersionID) so callers can match them
with errors.Is.

Refs: TIG-8441

Assisted-by: Opus 4.8 via Claude Code
Signed-off-by: Xe Iaso <xe@tigrisdata.com>
@Xe
Xe force-pushed the Xe/soft-delete branch from 540dd7e to c1bba74 Compare June 1, 2026 17:49
Xe added 6 commits June 1, 2026 13:54
The reviewer flagged the objectURL doc comment as implying an explicit
escape call. Verified empirically that assigning the decoded key to
url.URL.Path round-trips correctly for %, ?, #, & and preserves / as path
separators (url.URL.String percent-encodes on serialization). url.PathEscape
is deliberately avoided because it would encode / separators and corrupt
multi-segment keys.

Tighten the doc comment to describe the mechanism precisely and add regression
cases (percent and reserved characters) to RestoreSoftDeletedObject's request
construction test.

Refs: TIG-8441

Assisted-by: Opus 4.8 via Claude Code
Signed-off-by: Xe Iaso <xe@tigrisdata.com>
Per team style, omit the definite article directly before "Tigris": the new
doc comments now read "See Tigris documentation[1]…". Covers the seven new
methods in softdelete.go and the WithSoftDelete/WithForceDelete helpers in
tigrisheaders. Pre-existing comments are left untouched.

Refs: TIG-8441

Assisted-by: Opus 4.8 via Claude Code
Signed-off-by: Xe Iaso <xe@tigrisdata.com>
ListSoftDeletedObjects read in.Bucket before checking for a nil input, panicking
on a nil pointer instead of returning ErrMissingBucket like the other reshaped
soft-delete helpers. Add the nil guard and cover it in the validation test.

Refs: TIG-8441

Assisted-by: Opus 4.8 via Claude Code
Signed-off-by: Xe Iaso <xe@tigrisdata.com>
…bject

The two structs share identical field names and types, so a direct
type conversion replaces the field-by-field struct literal. Resolves
staticcheck S1016.

Assisted-by: Claude Opus 4.8 via Claude Code
Signed-off-by: Xe Iaso <xe@tigrisdata.com>
Add ListSoftDeletedBuckets, which issues a bucket listing with the
OnlyDeleted query parameter so the server returns only soft-deleted
buckets, each enriched with its retention window. Pagination follows
the ContinuationToken/MaxBuckets contract used by the dashboard
listing API.

Cover request construction, response parsing, nil input, and HTTP
errors with unit tests; extend the live lifecycle integration test to
find a force-deleted bucket in the listing; and add a godoc example.

Assisted-by: Claude Opus 4.8 via Claude Code
Signed-off-by: Xe Iaso <xe@tigrisdata.com>
The README covered snapshots, forks, and object rename, but nothing on
soft delete. Readers had to find the godoc examples to learn that the
retention window is 7 days by default and that ForceDeleteBucket and
PermanentlyDeleteObject are irreversible without soft delete.

Refs: TIG-8441
Assisted-by: Claude Opus 5 via Claude Code
Signed-off-by: Xe Iaso <xe@tigrisdata.com>
@Xe
Xe requested a review from katieschilling August 10, 2026 16:59
Three of the new methods could not work against a real gateway. All three
were masked by fixtures and parameter names that mirrored the client's own
assumptions rather than the server's output.

ListSoftDeletedObjects decoded only DeleteMarker elements, so it always
returned zero objects. Tigris emits soft-delete tombstones as Version
elements carrying SoftDeleted, because the S3 DeleteMarker schema has
nowhere to put Size or ETag. Decode Version and filter on the flag, which
also excludes live versions and real delete markers. This was the worst of
the three: the listing is how a caller obtains the VersionID that
RestoreSoftDeletedObject and PermanentlyDeleteObject require.

doSignedRequest signed with a bare v4.NewSigner, so the already-encoded
path was escaped a second time and no longer matched the single-encoded
canonical URI Tigris computes. Any key holding a space, "%", "+", "#",
"?", or a non-ASCII byte failed with SignatureDoesNotMatch. Set
DisableURIPathEscaping, as the S3 client itself does. Only
RestoreSoftDeletedObject puts a key in the path, so BundleObjects was
never affected.

ListSoftDeletedBuckets sent PascalCase pagination parameters, which the
server reads case-sensitively as continuation-token and max-buckets, and
parsed IsTruncated and NextContinuationToken, which the server never
sends. Both directions were silently dropped, making pages past the first
unreachable. Send the kebab-case names and derive IsTruncated from
ContinuationToken.

Also in this change:

- ForceDeleteBucket validates its input instead of panicking inside the
  AWS SDK on a nil pointer. It is the most destructive method here, so it
  fails closed like its siblings.
- doSignedRequest honors an HTTPClient configured on the S3 client, so a
  custom transport, TLS config, proxy, or tracing hook is no longer
  discarded by any of the six raw-HTTP methods.
- A negative RetentionDays goes to the server rather than silently
  becoming the 7-day default. Only 0 means "use the default".
- BundleObjects returns ErrMissingBucket, and guards a nil input.
- doSignedRequest drops its inner "storage: " prefix, which callers
  already add.
- Doc comments record that the five raw-HTTP methods ignore s3.Options,
  that version IDs are unix-nano timestamps, that X-Tigris-Soft-Delete
  means different things per route, and that RetentionDays is the
  configured window rather than the time remaining.

The fixtures now come from marshalling the gateway's own
ListVersionsResult and ListAllMyBucketsResult structs. A new signing test
re-signs the received request both ways and asserts the client matched the
single-encoded form. Each fix was confirmed to fail without its change.

Refs: TIG-8441
Assisted-by: Claude Opus 5 via Claude Code
Signed-off-by: Xe Iaso <xe@tigrisdata.com>
Comment thread softdelete.go
Comment thread softdelete.go Outdated
Comment thread softdelete.go
Xe added 2 commits August 11, 2026 10:40
Both descriptions came from reading the wire format rather than from the
server's intent, and both were wrong in a way a caller could act on.

The version ID of a soft-deleted object is a version ID on a versioned
bucket and the last-modified time of the object on a regular bucket. Both
happen to be nanoseconds since the Unix epoch, but a caller that reads the
value as a deletion timestamp will compute the wrong thing on a versioned
bucket. Describe both forms and say to treat the value as opaque. The same
correction applies to the versionID argument of PermanentlyDeleteObject,
which carried the same claim.

CreationDate on a soft-deleted bucket is the time the bucket was last
modified, not the time it was last created, and it equals
InitialCreatedDate when the bucket was never modified.

Refs: TIG-8441
Assisted-by: Claude Opus 5 via Claude Code
Signed-off-by: Xe Iaso <xe@tigrisdata.com>
The raw-HTTP methods reported a rejection as a formatted string, so a
caller had to parse err.Error() to learn why the request failed. The
methods that go through the embedded *s3.Client return errors that
errors.As can read, and these should behave the same way.

httpError now returns an *APIError carrying the operation, the HTTP status
code, and the error code, message, and request ID from the response body.
It implements smithy.APIError, so one errors.As call reads a code from
either error source, and HTTPStatusCode matches the accessor the AWS SDK
response errors expose. A body that is not an S3 error document keeps its
raw text in Message, so a proxy or gateway failure is not swallowed.

BundleObjects built the same string inline and now shares httpError, which
gives the whole raw-HTTP path one error type.

Refs: TIG-8441
Assisted-by: Claude Opus 5 via Claude Code
Signed-off-by: Xe Iaso <xe@tigrisdata.com>
@Xe
Xe merged commit 830a738 into main Aug 11, 2026
10 checks passed
Xe added a commit that referenced this pull request Aug 11, 2026
# [0.8.0](v0.7.0...v0.8.0) (2026-08-11)

### Features

- add soft-delete lifecycle support ([#28](#28)) ([830a738](830a738))

Cut by hand because the Cut Release workflow is broken.

Assisted-by: Claude Opus 5 via Claude Code
Signed-off-by: Xe Iaso <xe@tigrisdata.com>
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.

3 participants