Write Methods
WarmHub has one write path: every mutation lands through the same operation pipeline. The TypeScript SDK gives you two ways to submit operations:
client.commit.apply(...)for call sites that already have operation arrays.OperationBuilderfor call sites that benefit from incremental construction and local validation.
Both submit add, revise, and retract operations through the same backend stream-append surface and return the same per-operation result shape.
Choosing an API
Section titled “Choosing an API”| Need | Prefer |
|---|---|
| Submit a small operation array directly | client.commit.apply(...) |
| Build operations across several branches or helper functions | OperationBuilder |
Run client-side preflight checks before any server call (and shape-data validation when constructed with { shapes }) | OperationBuilder |
| Preserve a raw operation payload from another system | client.commit.apply(...) |
| Chain add, revise, and retract calls fluently | OperationBuilder |
| Resume caller-managed stream chunks | Either, with advanced stream options |
await client.commit.apply('acme', 'world', 'seed cave', [ { operation: 'add', name: 'Location/cave', data: { x: 0, y: 0 }, },])import { OperationBuilder } from '@warmhub/sdk-ts'
const builder = new OperationBuilder()builder.add({ name: 'Location/cave', data: { x: 0, y: 0 } })builder.add({ name: 'Location/forest', data: { x: 5, y: 3 } })
const check = builder.validate()if (!check.valid) { throw new Error(check.errors.map((e) => e.message).join('; '))}
await builder.commit({ client, orgName: 'acme', repoName: 'world', message: 'seed locations',})The builder has no .build() step and is not itself a promise — await builder does nothing. builder.commit({...}) is the only finalizer; it validates, submits, and seals the builder so calling builder.commit(...) a second time throws.
Typing Operation arrays
Section titled “Typing Operation arrays”Operation is a discriminated union over AddOperation, ReviseOperation,
and RetractOperation, keyed on the operation field. When the array is
passed inline to client.commit.apply, the parameter type narrows the
literal for you and the call typechecks with no extra ceremony.
When you bind the array to a variable first without a type annotation,
TypeScript widens operation: "add" to operation: string, and the
variable no longer assigns to the Operation[] parameter. Two equivalent
fixes — pick whichever fits the call site:
import type { Operation } from "@warmhub/sdk-ts";
// 1. Annotate the variable — contextually typed by the annotation.const operations: Operation[] = [ { operation: "add", kind: "thing", name: "Sensor/temp-1", data: { x: 1 } }, { operation: "revise", name: "Sensor/temp-1", data: { x: 2 } },];
// 2. Or `satisfies` — preserves the inferred literal types instead of// widening them to `Operation`.const operations2 = [ { operation: "add", kind: "thing", name: "Sensor/temp-1", data: { x: 1 } }, { operation: "revise", name: "Sensor/temp-1", data: { x: 2 } },] satisfies Operation[];
await client.commit.apply("acme", "world", "seed", operations);as const works too, at the cost of marking the whole array readonly.
Kind Inference
Section titled “Kind Inference”When kind is omitted, both write surfaces — client.commit.apply and OperationBuilder — infer it with the same shared rule, applied in order:
aboutpresent -> assertiontypeandmembersboth present -> collection- one-segment name (e.g.
game-state) -> thing - two-segment
Shape/name-> thing - three or more segments -> assertion
The rule is identical across surfaces; only where an invalid result is rejected differs. OperationBuilder rejects at .add()/.revise() time (a one-segment thing name fails the local-path preflight; an inferred assertion without about fails immediately rather than at commit()). client.commit.apply rejects while normalizing the operation or server-side, since the backend requires an explicit kind on every operation and never infers.
Shape adds always require explicit kind: 'shape' — a bare shape name (e.g. Player) is otherwise inferred as a thing and rejected as a thing-path violation. Use kind: 'thing' for hierarchical thing names such as GameState/round-1/state if you need to keep them on the thing path despite the segment count. Collection adds require both type and members; supplying only one of the two is rejected on both surfaces.
The same name-segmentation rule applies to kind-less revise operations.
The wh CLI shorthand is a separate, explicit-kind surface: it always sends a kind (defaulting to thing, or assertion when --about is supplied), so SDK inference never applies to CLI-built operations. See the write submit deep dive for the CLI’s defaulting rules.
Wref constraints are enforced server-side. An untyped wref accepts shapes and shaped things. A typed constraint uses the target’s resolved type: Player/alice can satisfy wref<Player>, while the Player shape itself has no governing resolved type and cannot. OperationBuilder validates field types and most local constraints, but it cannot prove a target’s resolved type until the operation reaches the server.
Version preconditions
Section titled “Version preconditions”revise accepts an optional expectedVersion — the write applies only if the target (thing, shape, or assertion) is still at that version, otherwise it is rejected with a CONFLICT (details.reason: "expected_version_mismatch"). Use it for read-modify-write safety when you don’t need to hold an exclusive lease. See Conditional Operations for an overview of all three conditional write patterns across surfaces.
Read leases
Section titled “Read leases”revise and retract operations accept an optional leaseId to write under a read lease acquired with client.thing.getWithLease — a leased read requires write access and is never an anonymous read. The field is per-operation, so the client.commit.apply signature is unchanged; add is never lease-gated (a new thing has no prior version to lease).
const leased = await client.thing.getWithLease("acme", "world", "Player/alice", { ttlMs: 5000 });
await client.commit.apply("acme", "world", "update score", [ { operation: "revise", name: "Player/alice", data: { score: 2 }, leaseId: leased.lease.id },]);// The lease auto-releases on a successful or no-op write. To bail out without writing,// call client.thing.releaseLease("acme", "world", "Player/alice", leased.lease.id).A successful (or no-op) write auto-releases the lease. If the lease has already expired, the write runs as an ordinary write — the same path you would take without a lease, following the usual version-conflict rules. But if another caller still holds the lease and your leaseId doesn’t match it, the write is rejected with LEASE_UNAVAILABLE.
Operation Results
Section titled “Operation Results”Successful submissions return operation results in input order — including noop-only batches, which resolve normally with each operation’s status set to 'noop'. Mixed results include partial: true, statusCounts, and per-operation error objects for failed entries. If every operation fails, client.commit.apply(...) and OperationBuilder.commit(...) reject with AllStreamOperationsFailedError. The one exception is a submission that had to be retried after an ambiguous transport failure — a network drop, timeout, or 5xx where the first attempt may already have reached the server: there the SDK raises PartialStreamSubmissionError instead, because it cannot rule out that some operations were applied. See Transient Retry for the full exception hierarchy and which catch target applies in each case.
Repository sequence acknowledgement
Section titled “Repository sequence acknowledgement”Both client.commit.apply(...) and OperationBuilder.commit(...) accept an optional returnRepoSeq flag. When set to true, the result includes a repoSeq property if the write produced at least one visible change.
repoSeq is the repo history position of the last visible change from this write. For writes that are submitted in multiple stream segments, WarmHub keeps the latest such value seen across all segments; segments that are all-failed or all-noop do not produce one. When no visible change was recorded — for example, a noop-only batch — repoSeq is omitted from the result entirely rather than set to undefined.
const result = await client.commit.apply( 'acme', 'world', 'seed cave', [{ operation: 'add', name: 'Location/cave', data: { x: 0, y: 0 } }], { returnRepoSeq: true },)console.log(result.repoSeq) // e.g. 42const result = await builder.commit({ client, orgName: 'acme', repoName: 'world', message: 'seed locations', returnRepoSeq: true,})console.log(result.repoSeq) // e.g. 42repoSeq is omitted when returnRepoSeq is omitted or false, or when the submission recorded no visible change (for example, a noop-only batch).
Each per-operation error object may carry an optional details field that, when present, narrows to one of several typed arms depending on the failure. Simple failures such as add-conflicts produce a code and message but no details. When details is present, narrow on details.reason to access the arm-specific fields exposed by the SDK result shape:
details.reason | Additional fields | When it appears |
|---|---|---|
"expected_version_mismatch" | expectedVersion: number; currentVersion: number | Version precondition failed |
"lease_held" | leaseExpiresAt: string (ISO 8601) | Another caller holds the lease and the supplied leaseId does not match |
"validation_failed" | issues: { path: string; message: string }[] | One or more fields on the submitted operation failed server-side validation; each entry in issues identifies the offending path and a human-readable message |
"rate_limit_reset" | retryAfterSeconds: number; resetAt: string (ISO 8601) | The operation was rejected because a rate limit was hit; retry after the indicated interval |
Check for details before narrowing on details.reason:
for (const op of result.operations) { if (op.status === 'error' && op.error?.details) { const { details } = op.error if (details.reason === 'validation_failed') { for (const issue of details.issues) { console.error(`Validation error at ${issue.path}: ${issue.message}`) } } else if (details.reason === 'rate_limit_reset') { console.warn(`Rate limited — retry after ${details.retryAfterSeconds}s (resets at ${details.resetAt})`) } }}Warnings are informational — a result can still be applied or no-op’d while carrying them. The warnings object is additive and can carry two kinds at once: undeclaredFields (top-level fields in the submitted data that the target shape does not declare) and coalescedWrefs (optional wref? fields whose resolver outcome was thing_absent and was coalesced to null — each entry carries fieldPath, wref, and reason). Missing shapes and every other failure remain hard errors. Each kind reports truncation (undeclaredFieldsTruncated / coalescedWrefsTruncated) with a full count when its list is capped. See Coalesced optional-wref warnings.
There is no commitId field. Version histories are the audit source; use client.thing.history(...) or client.shape.history(...) when you need to inspect what changed over time.