Repository navigation
feat: add soft-delete lifecycle support - #28
Conversation
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>
There was a problem hiding this comment.
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.goaddsCreateBucketWithSoftDelete,ListSoftDeletedObjects,RestoreSoftDeletedObject,RestoreBucket,ForceDeleteBucket,PermanentlyDeleteObject,SetBucketSoftDeleteonstorage.Client. - New
rawhttp.goextracts the shared SigV4 signing/transport helper (doSignedRequest,tigrisHTTPClient,baseEndpoint) used by both bundle and soft-delete code paths;bundle.gois refactored to use it. tigrisheadersgainsWithSoftDelete([days])andWithForceDelete(), plus tests and examples;example_test.goadds runnable examples for each newClientmethod.
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.
Greptile SummaryThis PR adds a full soft-delete lifecycle to the
Confidence Score: 3/5Safe 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
Reviews (1): Last reviewed commit: "feat(storage): add soft-delete lifecycle..." | Re-trigger Greptile |
…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>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ 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.
… 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>
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>
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>
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>
# [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>

Summary
storageandtigrisheaderspackages (TIG-8441).tigrisheaders.WithSoftDeletebucket-create option with listing, restore, force/permanent delete, and existing-bucket enable/disable.Details
storage.Clientgains:CreateBucketWithSoftDeleteListSoftDeletedObjectsSize,ETag,SoftDeleted,VersionID,LastModified(with pagination)RestoreSoftDeletedObjectRestoreBucketForceDeleteBucketPermanentlyDeleteObjectSetBucketSoftDeletetigrisheadersgainsWithForceDelete()(X-Tigris-Force-Delete: true).Operations the S3 SDK cannot express (restore object/bucket, the enriched
versions listing, and the
PATCHbucket config) go through a sharedSigV4-signed raw-HTTP path extracted from
bundle.gointo a newrawhttp.go(
doSignedRequest);BundleObjectsnow reuses it instead of duplicating thesigning logic. Method names (
RestoreSoftDeletedObject,ForceDeleteBucket,PermanentlyDeleteObject) are chosen to avoid shadowing the embedded*s3.Clientmethods. Object keys are escaped vianet/urlso the signedcanonical URI matches the request that is sent.
Test plan
go build ./...npm run formatgo test ./...passes (httptest request-construction tests assert method/path/query/headers/SigV4; XML-decode and validation tests; credential-gatedTestSoftDeleteLifecycle_integrationskips without creds)go vet ./...cleanRefs: 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 usetigrisheaders(WithSoftDelete, newWithForceDelete); list/restore/PATCH flows use SigV4 raw HTTP because the S3 SDK cannot express those endpoints.rawhttp.gocentralizes signing (doSignedRequest,DisableURIPathEscapingfor object keys),APIError(smithy.APIError), and shared endpoint helpers.BundleObjectsnow 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.