Upload & download
Write and read object bytes with the Lockwell native and S3 clients, including buffered and streaming PutObject, GetObject, ranges, versions, and HeadObject.
This page covers writing and reading object bytes. It walks PutObject (buffered and streaming), GetObject
(whole-object and streaming, ranges, versions, response overrides), and HeadObject.
The examples for the legacy HTTP/JSON surface lead with the native client and show the S3 client right after, behind the
surface toggle. New Go applications should use the direct binary lockwellwire client (in lockwell-sdk-go)
described in the
Go SDK guide. Two clients
write to the same encrypted, per-tenant store:
- the legacy native client uses an auto-managed JSON bearer token (
NativeClient/lockwellnative/LockwellNativeClient); - the S3 client signs every request with SigV4 (
Client/lockwellsdk/LockwellClient).
For the operation matrix at a glance, see the native data-plane reference and the S3 operations reference. Copy, list, delete, and conditional writes have their own pages, linked at the bottom.
Constructing a client
The native client takes an endpoint plus an access-key id and secret, then mints and refreshes its own bearer token (single-flight, thread-safe). The S3 client takes the same credentials and signs each request directly.
import { NativeClient, Client } from "@kelphect/sdk";
// Native JSON data plane (token managed for you):
const native = new NativeClient({
endpoint: "https://objects.example.com", // public listener; /api/v1 is added for you
accessKeyId: process.env.LOCKWELL_ACCESS_KEY_ID,
secretKey: process.env.LOCKWELL_SECRET_KEY,
});
// S3 data plane (SigV4):
const s3 = new Client({
endpoint: "https://objects.example.com",
accessKeyId: process.env.LOCKWELL_ACCESS_KEY_ID,
secretKey: process.env.LOCKWELL_SECRET_KEY,
});The rest of this page uses native for the native client and s3 for the S3 client.
Put an object (buffered)
Hand the SDK a byte buffer and it does one request. The native client returns the stored object's ETag and, on a versioned bucket, a version id.
const put = await native.putObject("reports", "q1/summary.txt", "hello world", {
contentType: "text/plain",
metadata: { author: "finance", quarter: "Q1" },
});
console.log(put.etag, put.versionId);The same with the S3 client:
const put = await s3.putObject("reports", "q1/summary.txt", Buffer.from("hello world"), {
contentType: "text/plain",
metadata: { author: "finance", quarter: "Q1" },
});
console.log(put.etag, put.versionId);User metadata is stored alongside the object and returned on every read. On the native path it travels as
X-Lockwell-Meta-* headers; on the S3 path as x-amz-meta-*. Both SDKs surface it as a plain key-value map (the prefix
is stripped for you).
PutObject options
| Option | Native (Go / Node / Java) | S3 (Go / Node / Java) | Effect |
|---|---|---|---|
| Content type | ContentType / contentType / .contentType | WithContentType / contentType / .contentType | Sets the stored media type. |
| User metadata | Metadata / metadata / .metadata | WithMetadata / metadata / .metadata | Arbitrary key-value pairs. |
| Idempotency key | IdempotencyKey / idempotencyKey / .idempotencyKey | WithIdempotencyKey / idempotencyKey / .idempotencyKey | Makes a retried write replay the stored result. See conditional writes. |
| Checksum | Checksums / checksums / .checksum | WithChecksumAlgorithm / checksumAlgorithm / .checksum | Server verifies and persists an end-to-end digest. See checksums. |
| Create-only | IfNoneMatch:"*" / ifNoneMatch:'*' / .ifAbsent() | WithPutIfNoneMatch("*") / ifNoneMatch: "*" / .ifNoneMatch("*") | Write only when the key is absent. See conditional writes. |
| Overwrite-only | IfMatch / ifMatch / .ifMatch | (use native or copy) | Write only when the current ETag matches. See conditional writes. |
| SSE-S3 | (always on at rest) | WithServerSideEncryption / serverSideEncryption / .serverSideEncryption | Requests server-managed encryption at rest. |
| Object Lock at write | set with setObjectRetention after write | WithObjectLock* headers via PutOptions | Apply retention or a legal hold. See object lock. |
Conditional create and overwrite are native-client features. The native putObject takes If-None-Match / If-Match
directly. The S3 PutObject does not, so for an existing object reach for a conditional copy.
Lockwell issues presigned GET/PUT/HEAD/DELETE URLs on the S3 client; for a browser-direct upload you may also use a
native signed URL.
The S3 `PutObject` supports create-only `If-None-Match: *`. Use native `putObject` for overwrite-only
If-Match, or a conditional copy for an existing key.
Encryption status and SSE-S3
The native client's encryption posture is a server deployment property. Production configs enable encryption, so native
writes are stored as encrypted chunks under per-tenant data keys and the native client has no SSE option. Native GET and
HEAD responses expose the X-Lockwell-Encrypted header; Java surfaces it as GetResult.encrypted() and
HeadResult.encrypted(). Do not run tenant-handling workloads on an encryption-disabled deployment.
On the S3 client, WithServerSideEncryption / serverSideEncryption: true / .serverSideEncryption() asks for SSE-S3:
a server-managed, per-tenant key encrypts the object at rest. The response echoes serverSideEncryption: "AES256".
SSE-KMS is a non-goal. The S3 wire and all three first-party S3 clients expose typed SSE-C helpers; supply the same raw
32-byte customer key on every applicable read, write, copy, multipart-part, and completion request, and never log or
persist it in application telemetry.
User metadata is a lossless, duplicate-preserving user namespace. The merged metadata contract stores it separately from
internal SSE-C and Object Lock state, including for historical names such as x-amz-meta-lockwell-sse-customer-key-md5.
Caller metadata cannot manufacture an internal encryption marker. This is a metadata-boundary guarantee, not permission
to use the native or LNW/1 surfaces for SSE-C; genuine customer-provided keys remain an S3-only capability.
const put = await s3.putObject("vault", "ledger.json", Buffer.from(data), {
serverSideEncryption: true,
});
console.log(put.serverSideEncryption); // "AES256"Put an object (streaming)
Hand the SDK a reader and the body goes to the server without ever sitting whole in memory. Reach for this when a file is larger than you want to buffer.
The native client streams the raw bytes. A streaming body cannot be replayed, so the client mints a fresh token before it starts streaming and a token-expiry 401 retry is never needed. A 401 from a genuinely revoked key still surfaces.
import { createReadStream } from "node:fs";
import { Readable } from "node:stream";
// A ReadableStream / async-iterable body streams without buffering:
const file = Readable.toWeb(createReadStream("./big.bin"));
await native.putObject("reports", "big.bin", file, {
contentType: "application/octet-stream",
contentLength: 1_048_576, // set so the server enforces the size cap + quota up front
});Set contentLength (native) where you know the size up front: it lets the server enforce the per-object size cap and
the tenant quota before it accepts a single byte, and it avoids chunked transfer encoding. For very large or resumable
uploads, use multipart upload instead.
The S3 client wraps the stream as an aws-chunked body with an end-to-end checksum trailer, so a checksum algorithm is
required on putObjectStream. An idempotency key is not supported for the S3 stream (the trailer is not known at
reservation time); use the buffered PutObject when you need idempotency.
import { createReadStream } from "node:fs";
// Checksum required; sent in an aws-chunked trailer:
await s3.putObjectStream("reports", "big.bin", createReadStream("./big.bin"), "CRC64NVME", {
contentType: "application/octet-stream",
});Get an object
GetObject streams the body. On both clients you own the body and must close it, and the result carries the object's
metadata: content type, length, ETag, version id, and any checksums.
// Streaming download (no whole-object buffering):
const obj = await native.getObjectStream("reports", "big.bin");
console.log(obj.contentType, obj.contentLength, obj.etag);
for await (const chunk of obj.body) {
/* ...process chunk... */
}
// Buffered convenience (small objects):
const small = await native.getObject("reports", "q1/summary.txt");
console.log(small.body.toString("utf8"), small.metadata);The same with the S3 client:
const s3obj = await s3.getObjectStream("reports", "big.bin");
const all = await s3obj.readAll(); // or iterate s3obj.bodyByte ranges
Pass a range to fetch part of an object. The server replies 206 Partial Content with a Content-Range. The native
client takes the raw HTTP Range value; the S3 client takes a typed range (start, end inclusive, end < 0 means "to
end").
const part = await native.getObjectStream("reports", "big.bin", { range: "bytes=0-1023" });
console.log(part.contentRange);const s3part = await s3.getObjectStream("reports", "big.bin", { range: "bytes=0-1023" });Reading a specific version
On a versioned bucket, pass a versionId to read a past version rather than the current one.
const old = await native.getObject("reports", "q1/summary.txt", { versionId });const s3old = await s3.getObject("reports", "q1/summary.txt", { versionId });Response-header overrides (S3 client)
The S3 GetObject can override the headers the server returns for this one read, so you can force a download filename
or a content type without rewriting the object. These are the standard S3 response-* query overrides.
const dl = await s3.getObjectStream("reports", "q1.csv", {
responseContentType: "text/csv",
});Reading one multipart part (S3 client)
WithPartNumber(n) on the S3 GetObject returns the byte range of a single multipart part (1-based) plus the total
part count in PartsCount. This lets a downloader fetch the object part by part with the same boundaries it was
uploaded with.
out, _ := s3.GetObject(ctx, "reports", "big.bin", lockwellsdk.WithPartNumber(1))
fmt.Println(out.PartsCount) // total partsHead an object
HeadObject returns the same metadata as a read with no body: content type, length, ETag, version id, checksums, and
encryption status. A missing object is a not-found error on both clients.
import { isNativeNotFound } from "@kelphect/sdk";
try {
const head = await native.headObject("reports", "q1/summary.txt");
console.log(head.contentLength, head.etag, head.metadata);
} catch (err) {
if (isNativeNotFound(err)) {
/* not there */
} else throw err;
}import { isNotFound } from "@kelphect/sdk";
try {
const head = await s3.headObject("reports", "q1/summary.txt");
console.log(head.contentLength, head.etag, head.metadata);
} catch (err) {
if (isNotFound(err)) {
/* not there */
} else throw err;
}Errors
Both clients raise a structured error carrying a stable code, the HTTP status, and a request id you can correlate with an audit row.
| Status | Meaning | Native guard | S3 guard |
|---|---|---|---|
| 404 | no such bucket/key | IsNotFound / isNativeNotFound | IsNotFound / isNotFound |
| 401 | bad/expired token or revoked key | IsUnauthorized / isNativeUnauthorized | (re-sign) |
| 403 | scope or policy denial | IsForbidden / isNativeForbidden | APIError code |
| 409 | already exists | IsAlreadyExists / isNativeConflict | APIError code |
| 412 | precondition not met | IsPreconditionFailed / isNativePreconditionFailed | APIError code |
| 507 | tenant storage quota exceeded | IsQuotaExceeded / isNativeQuotaExceeded | APIError code |
In Java, catch NativeException (native) or ApiException (S3) and branch on statusCode() / code(). Full handling,
including the retry policy, is on Errors & retries.
Next steps
- Listing & pagination: find the objects you stored.
- Copying objects: server-side copy, conditionals, large-object part copy.
- Deleting objects: single, batch, and versioned deletes.
- Conditional writes: create-only, overwrite-only, idempotency.
- Multipart uploads: resumable uploads for very large objects.
- Signed URLs: hand a browser a direct upload or download URL.
Copying objects
Duplicate objects server-side with Lockwell CopyObject, including cross-bucket copies, source and destination conditionals, and large-object UploadPartCopy.
Deleting objects
Delete one object, a specific version, or up to 1000 objects in a batch with Lockwell, and understand how delete markers interact with versioning.