Lockwell home
Guide

Upload & download

Write and read object bytes with the Lockwell native and S3 clients, including buffered and streaming PutObject, GetObject, ranges, versions, and HeadObject.

Download .md

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

OptionNative (Go / Node / Java)S3 (Go / Node / Java)Effect
Content typeContentType / contentType / .contentTypeWithContentType / contentType / .contentTypeSets the stored media type.
User metadataMetadata / metadata / .metadataWithMetadata / metadata / .metadataArbitrary key-value pairs.
Idempotency keyIdempotencyKey / idempotencyKey / .idempotencyKeyWithIdempotencyKey / idempotencyKey / .idempotencyKeyMakes a retried write replay the stored result. See conditional writes.
ChecksumChecksums / checksums / .checksumWithChecksumAlgorithm / checksumAlgorithm / .checksumServer verifies and persists an end-to-end digest. See checksums.
Create-onlyIfNoneMatch:"*" / ifNoneMatch:'*' / .ifAbsent()WithPutIfNoneMatch("*") / ifNoneMatch: "*" / .ifNoneMatch("*")Write only when the key is absent. See conditional writes.
Overwrite-onlyIfMatch / ifMatch / .ifMatch(use native or copy)Write only when the current ETag matches. See conditional writes.
SSE-S3(always on at rest)WithServerSideEncryption / serverSideEncryption / .serverSideEncryptionRequests server-managed encryption at rest.
Object Lock at writeset with setObjectRetention after writeWithObjectLock* headers via PutOptionsApply 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.body

Byte 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 parts

Head 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.

StatusMeaningNative guardS3 guard
404no such bucket/keyIsNotFound / isNativeNotFoundIsNotFound / isNotFound
401bad/expired token or revoked keyIsUnauthorized / isNativeUnauthorized(re-sign)
403scope or policy denialIsForbidden / isNativeForbiddenAPIError code
409already existsIsAlreadyExists / isNativeConflictAPIError code
412precondition not metIsPreconditionFailed / isNativePreconditionFailedAPIError code
507tenant storage quota exceededIsQuotaExceeded / isNativeQuotaExceededAPIError 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

On this page