Lockwell home
Guide

Copying objects

Duplicate objects server-side with Lockwell CopyObject, including cross-bucket copies, source and destination conditionals, and large-object UploadPartCopy.

Download .md

CopyObject duplicates an object server-side, so the bytes never travel back to your process. Use it to move a key, branch a version, change metadata, or apply encryption on the copy. For objects too large to copy in one request, the S3 UploadPartCopy assembles a new object from ranges of existing ones.

Copies stay within the tenant. On the native path the source resolves under the token's tenant, so a cross-tenant copy is impossible by construction.

For the operation matrix, see the native data-plane reference and the S3 operations reference.

Copy within a bucket

Name the destination (bucket, key) and the source (bucket, key, and an optional source version id). Leave the source version unset to copy the current version.

const res = await native.copyObject("reports", "q1/summary-copy.txt", {
  sourceBucket: "reports",
  sourceKey: "q1/summary.txt",
});
console.log(res.etag, res.versionId);

The S3 client names the source first, then the destination:

const res = await s3.copyObject("reports", "q1/summary.txt", "", "reports", "q1/summary-copy.txt");
console.log(res.etag, res.versionId);

Copy across buckets

The destination bucket can differ from the source bucket. Both must belong to the same tenant.

await native.copyObject("archive", "imports/upload.csv", {
  sourceBucket: "inbox",
  sourceKey: "upload.csv",
});
await s3.copyObject("inbox", "upload.csv", "", "archive", "imports/upload.csv");

Copy-source conditionals

Gate the copy on the state of the source object. The copy proceeds only if the condition holds; otherwise it fails with a 412 PreconditionFailed.

ConditionEffect
If-Match <etag>Copy only if the source ETag matches.
If-None-Match <etag>Copy only if the source ETag does not match.
If-Modified-Since <http-date>Copy only if the source changed since the date.
If-Unmodified-Since <http-date>Copy only if the source has not changed since the date.

The native client takes all four as ifMatch / ifNoneMatch / ifModifiedSince / ifUnmodifiedSince. The S3 client takes them as WithCopyIf* options.

The Java S3 `copyObject` accepts only an `ifMatch` argument. For the other three source conditionals on

Java, use the native client.

await native.copyObject("reports", "q1/pinned.txt", {
  sourceBucket: "reports",
  sourceKey: "q1/summary.txt",
  ifUnmodifiedSince: "Wed, 01 Jan 2026 00:00:00 GMT",
});
// Copy only if the source still has the ETag we expect:
await s3.copyObject("reports", "q1/summary.txt", "", "reports", "q1/pinned.txt", {
  ifMatch: '"d41d8cd98f00b204e9800998ecf8427e"',
});

Destination conditionals (native client)

The native copy also gates on the destination atomically at the commit: requireAbsent copies only when the destination key does not exist (a copy-time create-only), and requireMatchEtag copies only when the destination's current ETag matches (a copy-time overwrite-only). These pair naturally with the conditional write patterns.

// Branch a snapshot, but never clobber an existing one:
await native.copyObject("snapshots", "latest.json", {
  sourceBucket: "live",
  sourceKey: "state.json",
  requireAbsent: true, // 412 if snapshots/latest.json already exists
});

Metadata directive: COPY vs REPLACE

By default a copy inherits the source's user metadata and content type (metadataDirective: "COPY"). Pass REPLACE to set fresh metadata and content type from the request instead.

On the native client, set the directive explicitly. On the S3 client, supplying copy metadata implies REPLACE (the SDK sets the directive for you when you pass metadata).

// Explicit REPLACE with a new content type + metadata:
await native.copyObject("reports", "q1/tagged.txt", {
  sourceBucket: "reports",
  sourceKey: "q1/summary.txt",
  metadataDirective: "REPLACE",
  contentType: "text/plain; charset=utf-8",
  metadata: { reviewed: "true" },
});
// Passing metadata replaces the destination's metadata (sets REPLACE):
await s3.copyObject("reports", "q1/summary.txt", "", "reports", "q1/tagged.txt", {
  metadata: { reviewed: "true" },
});

With COPY (the default), the source's content type and every X-Lockwell-Meta-* / x-amz-meta-* entry carry over unchanged.

Encryption on the copy (S3 client)

The native store is always encrypted at rest, so the native copy has no SSE option. On the S3 client WithCopyServerSideEncryption / serverSideEncryption: true requests SSE-S3 for the destination object, so you can encrypt a previously unencrypted object by copying it onto itself or to a new key.

_, err := s3.CopyObject(ctx, "vault", "ledger.json", "", "vault", "ledger.json",
    lockwellsdk.WithCopyServerSideEncryption(),
)

Large objects: UploadPartCopy (S3 client)

A single CopyObject copies the whole object in one request. For an object too large for that, or to assemble a new object from ranges of existing ones, copy parts: start a multipart upload on the destination, then call UploadPartCopy for each part, naming a source object and an optional byte range. Complete the upload with the returned part ETags.

UploadPartCopy lives on the S3 client. The native multipart path uploads part bytes rather than copying ranges; see multipart uploads. Each part except the last must meet the multipart minimum part size.

const { uploadId } = await s3.createMultipartUpload("archive", "merged.bin");

// Each part copies a byte range from a source object (no bytes through your app):
const p1 = await s3.uploadPartCopy("inbox", "a.bin", "", "archive", "merged.bin", uploadId, 1, "bytes=0-5242879");
const p2 = await s3.uploadPartCopy("inbox", "b.bin", "", "archive", "merged.bin", uploadId, 2, ""); // whole object

await s3.completeMultipartUpload("archive", "merged.bin", uploadId, [
  { partNumber: 1, etag: p1.etag },
  { partNumber: 2, etag: p2.etag },
]);

Next steps

On this page