Skip to content

Write Methods

WarmHub has one write path: every mutation lands through the same operation pipeline. Both SDKs share two ways to submit operations — a one-shot call and a builder. TypeScript additionally offers applyStreaming for constant-memory streaming submissions:

  • A one-shot call for call sites that already have operation arrays.
  • OperationBuilder for call sites that benefit from incremental construction and local validation.
  • client.commit.applyStreaming(...) for full-duplex streaming without buffering results (TypeScript only).

The spellings differ:

TypeScriptPython
One-shot submitclient.commit.apply(org, repo, message, ops)repo.apply(message, ops)
Buildernew OperationBuilder(), then builder.commit({ client, orgName, repoName, message })repo.batch(message=…), then batch.commit()
Server-side previewclient.commit.validate(org, repo, ops, opts)repo.validate(ops, message=…)

In Python the ergonomic path is a repository handle — repo = client.repository("acme/world") — so the org and repo aren’t repeated at every call site. client.commit.apply(...) and client.commit.batch(...) also exist for callers that need the lower-level namespace directly. Python builds operations from typed constructors (Add, Revise, Retract, Reaffirm, Rename) rather than object literals.

To evaluate an existing operation array with server truth before writing, use client.commit.validate(...). It shares the real commit evaluator but never persists its staged effects.

Both submit operations through the same write path and return the same per-operation result shape. The one-shot call accepts the full Operation union — including RenameOperation — in both languages. The builders differ in coverage: the TypeScript OperationBuilder exposes add, revise, retract, and reaffirm, so a rename has to go through client.commit.apply(...); the Python builder adds rename, so it covers the whole union. For the full rename payload shape and rename-specific rules, see Write Operations and the RenameOperation SDK reference (TypeScript).

Named by role, since the spellings differ per language — see the table above.

NeedPrefer
Submit a small operation array directlyOne-shot submit
Build operations across several branches or helper functionsBuilder
Run client-side preflight checks before any server callBuilder
Also validate data against shapes client-sideBuilder with shapes ({ shapes } in TypeScript; shapes= in Python)
Preserve a raw operation payload from another systemOne-shot submit
Chain add, revise, and retract calls fluentlyBuilder
Submit rename operationsOne-shot submit in TypeScript; either in Python
Preview one complete bounded batch with the server evaluatorServer-side preview
Consume committed groups incrementally without aggregating resultsapplyStreaming (TypeScript only)
Tag a caller-managed stream for diagnosticsEither, with advanced stream options
await client.commit.apply('acme', 'world', 'seed cave', [
{
operation: 'add',
name: 'Location/cave',
data: { x: 0, y: 0 },
},
])
const preview = await client.commit.validate(
'acme',
'world',
[
{
operation: 'add',
name: 'Location/cave',
data: { x: 0, y: 0 },
},
],
{ message: 'seed cave', includeWouldBeBody: true },
)
if (!preview.canCommit) {
for (const operation of preview.operations) {
if (operation.status === 'error') console.error(operation.errors)
}
}

commit.validate accepts message, committer, componentRef, skipExisting, includeWouldBeBody, and signal (an AbortSignal) — in Python the same options are snake_case keywords and there is no signal. It normalizes the same public Operation union as commit.apply, then sends the complete array in one unbatched request. Limits are 10,000 operations, 4 MiB of encoded request data, a 120-second request deadline, and a 15-second maximum database statement. It does not chunk, retry as a write, allocate a submission ID, or consume a receipt.

Each result uses the common operation base (opIndex, operation, name, and optional warnings/affirmations). Preview statuses are would_apply, noop, or error; errors are plural structured diagnostics, and one-to-many collection lowering appears in ordered effects. A failed effect makes its parent input an error and valid siblings are marked discarded with reason sibling_failed. wouldBeBody is opt-in. A submitted body may be returned to an authorized writer; a body derived from stored data requires read authority. The baseline is either a disclosable repo_seq or explicitly withheld.

The preview is a snapshot. It does not reserve the repository state, run write-time receipt/admission checks, or project asynchronous actions, so a later real commit can still fail.

client.commit.applyStreaming(...) is the explicit constant-memory alternative to aggregate commit.apply. It accepts an OperationSource (an array, sync iterable, or async iterable) and returns a lazy StreamingSubmissionHandle synchronously:

const handle = client.commit.applyStreaming(
'acme',
'world',
'bulk import',
operations,
{ groupSize: 1000, streamId: 'nightly-import' },
)
console.log(handle.retryIdentity) // available before auth or network work
for await (const row of handle) {
if (row.type === 'result') processResult(row)
if (row.type === 'group') recordReceiptBoundary(row)
if (row.type === 'summary') console.log(row.verdict)
}

The first next() starts authentication, source acquisition, and one full-duplex NDJSON request. The handle is single-use and supported by Bun and Node.js 22.2+ with native fetch; browser-like runtimes reject before acquiring the source. Breaking the loop or aborting its signal cancels both request and response.

An explicit non-2xx response keeps normal WarmHubError semantics, including a single, non-retried UNAUTHENTICATED response. A transport loss or malformed protocol after dispatch throws StreamingSubmissionOutcomeUnknownError. Its retryable field and isRetryable(error) are both false: reconstruct the full normalized operation source and copy every field from retryIdentity back into the positional arguments and options:

const id = error.retryIdentity
const retry = client.commit.applyStreaming(
id.orgName,
id.repoName,
id.message,
reconstructOriginalOperations(),
{
streamId: id.streamId,
submissionId: id.submissionId,
groupSize: id.groupSize,
committer: id.committer,
},
)

retryIdentity is not itself an accepted options property. Changing the repository, stream, submission ID, group size, normalized message/committer, or operation order changes durable request identity; a credential rotation is safe only when it resolves to the same durable actor and committer binding.

If applyStreaming is unavailable for a repo, the call returns NOT_FOUND — fall back to aggregate commit.apply(...).

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 or awaitable — await builder does nothing in TypeScript, and the Python builder accepts no await at all. commit(...) is the only finalizer; it validates, submits, and seals the builder so calling commit(...) a second time throws.

This section is TypeScript-only: Python builds operations from typed constructors, so there is no literal to widen and no annotation to add. [Add(name="Sensor/temp-1", data={"x": 1})] already types as list[Operation].

Operation is a discriminated union over AddOperation, ReviseOperation, RetractOperation, and RenameOperation, 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.

When kind is omitted, both write surfaces — client.commit.apply and OperationBuilder — infer it with the same shared rule, applied in order:

  • about present -> assertion
  • type and members both 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.

revise and retract accept an optional expectedVersion (expected_version in Python) — the write applies only if the target (thing, shape, assertion, or collection) 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.

revise and retract operations accept an optional leaseId (lease_id in Python) 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.

Successful submissions return an OperationSubmitResult (SubmitResult in Python): the aggregate write result (operationCount, operations[], and partial plus statusCounts when any operation failed) with the ordered OperationEventReceipt receipts nested under receipts, one receipt per chunk. Each receipt contains its caller-known request ID, request digest, event or no-event outcome, and the existing ordered operation envelopes.

Result and error field names below are given in their TypeScript spelling. Python exposes the same fields snake_cased — operation_count, status_counts, repo_seq, retry_after_seconds — with no other structural difference.

An event receipt carries event.repoSeq as decimal text. A no-event receipt has event: null (None in Python) and no sequence.

const result = await client.commit.apply(
'acme',
'world',
'seed cave',
[{ operation: 'add', name: 'Location/cave', data: { x: 0, y: 0 } }],
)
console.log(result.receipts[0]?.event?.repoSeq) // e.g. "42"
const result = await builder.commit({
client,
orgName: 'acme',
repoName: 'world',
message: 'seed locations',
})
console.log(result.receipts[0]?.event?.repoSeq)

If a later stream chunk fails after earlier chunks were acknowledged, the error’s completed-receipts field (completedReceipts in TypeScript, completed_receipts in Python) preserves their exact repository sequences. Use the pending chunk’s event-request ID (eventRequestId / event_request_id) for receipt lookup; do not infer its outcome from the earlier high-water mark. See Transient Retry (TypeScript).

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.reasonAdditional fieldsWhen it appears
"expected_version_mismatch"expectedVersion: number; currentVersion: numberVersion 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
"cursor_fence_unavailable"cause: "not_ready" | "below_floor" | "above_head"; retryFromStart: trueThe streaming cursor fence could not be satisfied; reconstruct the full operation source and retry from the beginning

Check for details before narrowing on details.reason:

for (const receipt of receipts) {
for (const op of receipt.operations) {
if (op.status === 'failed' && 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.