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.
OperationBuilderfor 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:
| TypeScript | Python | |
|---|---|---|
| One-shot submit | client.commit.apply(org, repo, message, ops) | repo.apply(message, ops) |
| Builder | new OperationBuilder(), then builder.commit({ client, orgName, repoName, message }) | repo.batch(message=…), then batch.commit() |
| Server-side preview | client.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 client.commit.apply accepts the full Operation union — including RenameOperation — in both languages. In TypeScript, client.commit.validate accepts a wider CommitValidateOperation union that additionally includes RemoveOperation, enabling hard-delete previews; the actual deletion executes through client.thing.remove(...). In Python, repo.validate accepts the same Operation union as repo.apply — RemoveOperation is not supported by the Python validator, so hard-delete previews are a TypeScript-only capability. Both builders cover the whole union: add, revise, retract, reaffirm, rename, and restore. For the full rename payload shape and rename-specific rules, see Write Operations and the RenameOperation SDK reference (TypeScript).
Choosing an API
Section titled “Choosing an API”Named by role, since the spellings differ per language — see the table above.
| Need | Prefer |
|---|---|
| Submit a small operation array directly | One-shot submit |
| Build operations across several branches or helper functions | Builder |
| Run client-side preflight checks before any server call | Builder |
| Also validate data against shapes client-side | Builder with shapes ({ shapes } in TypeScript; shapes= in Python) |
| Preserve a raw operation payload from another system | One-shot submit |
| Chain add, revise, and retract calls fluently | Builder |
| Submit rename operations | One-shot submit in TypeScript; either in Python |
| Preview one complete bounded batch with the server evaluator | Server-side preview |
| Preview a hard delete before executing it (TypeScript only) | Server-side preview with remove operation, then client.thing.remove(...) to execute |
| Consume committed groups incrementally without aggregating results | applyStreaming (TypeScript only) |
| Tag a caller-managed stream for diagnostics | Either, with advanced stream options |
await client.commit.apply('acme', 'world', 'seed cave', [ { operation: 'add', name: 'cave', shapes: ['Location'], data: { x: 0, y: 0 }, },])from warmhub import Add, WarmHubClient
with WarmHubClient.from_env() as client: world = client.repository("acme/world") world.apply("seed cave", [ Add(name="cave", shapes=["Location"], data={"x": 0, "y": 0}), ])const preview = await client.commit.validate( 'acme', 'world', [ { operation: 'add', name: 'cave', shapes: ['Location'], 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) }}preview = world.validate( [Add(name="cave", shapes=["Location"], data={"x": 0, "y": 0})], message="seed cave", include_would_be_body=True,)
if not preview.can_commit: for operation in preview.operations: if operation.status == "error": print(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. In TypeScript it accepts the
CommitValidateOperation union, which extends the public Operation union with
RemoveOperation — pass a remove operation to preview a hard
delete before committing (client.commit.validate(..., [{ operation: "remove", ... }])),
then execute the actual deletion through client.thing.remove(...); streamed commit.apply refuses
remove operations. In Python, repo.validate accepts the same Operation union as repo.apply;
RemoveOperation is not supported by the Python validator and will be rejected client-side. The full array is sent 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.
Incremental streaming submissions
Section titled “Incremental streaming submissions”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 workfor 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.retryIdentityconst 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('cave', { shapes: ['Location'], data: { x: 0, y: 0 } })builder.add('forest', { shapes: ['Location'], 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',})batch = world.batch(message="seed locations")batch.add("cave", shapes=["Location"], data={"x": 0, "y": 0})batch.add("forest", shapes=["Location"], data={"x": 5, "y": 3})
check = batch.validate()if not check.valid: raise RuntimeError("; ".join(e.message for e in check.errors))
result = batch.commit()repo.batch(...) returns a builder already bound to the repository and carrying
the commit options, so commit() takes no client, org, repo, or message.
add(), revise(), and retract() return the builder, so calls chain.
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.
Typing Operation arrays
Section titled “Typing Operation arrays”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="temp-1", shapes=["Sensor"], data={"x": 1})] already types as
list[Operation].
Operation is a discriminated union over AddOperation, ReviseOperation,
RetractOperation, ReaffirmOperation, RenameOperation, and RestoreOperation, 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", name: "temp-1", shapes: ["Sensor"], data: { x: 1 } }, { operation: "revise", name: "temp-1", shapes: ["Sensor"], data: { x: 2 } },];
// 2. Or `satisfies` — preserves the inferred literal types instead of// widening them to `Operation`.const operations2 = [ { operation: "add", name: "temp-1", shapes: ["Sensor"], data: { x: 1 } }, { operation: "revise", name: "temp-1", shapes: ["Sensor"], 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.
Names and Shapes
Section titled “Names and Shapes”A write operation has no kind, and the SDK infers nothing from a name. Two fields decide what an operation writes:
nameis the Thing’s whole name. A/inside it is part of the name, not a Shape separator.shapeslists the Shapes the Thing declares, by bare name (["Player"]). Everyaddandrevisecarries it, and[]writes a shapeless Thing.
The other record types follow from those two fields:
- A Shape is the Thing
hub/shapes/<Name>, written withshapes: []. - An assertion is a Thing that declares a Shape composing the built-in
AssertionShape, with its target indata.about. - A collection is a Thing that declares
Arc,Bond,Set, orList, with its members indata.
await client.commit.apply("acme", "world", "seed", [ { operation: "add", name: "hub/shapes/Player", shapes: [], data: { fields: { score: "number" } } }, { operation: "add", name: "alice", shapes: ["Player"], data: { score: 0 } }, { operation: "add", name: "team-a", shapes: ["Set"], data: { members: ["alice"] } },]);These rules describe raw operations, as client.commit.apply takes them. client.commit.apply checks every operation before it sends anything, so one refusal sends nothing. An operation in the v1 form is refused, and the error shows the same operation rewritten in the current form. The v1 form is any operation with a kind, shape, wref, declaredShapes, type, or members field, a top-level about, or no operation. An add with no shapes is refused too. So is a revise that has no shapes and does not set keepShapes: true, which tells the SDK to read the Thing’s current Shapes at submit.
OperationBuilder states Shapes through its options instead. Each .add() or .revise() takes exactly one of shapes, shapeless: true (the builder’s spelling of a shapeless Thing; it refuses shapes: [] for any name but a Shape), or, on a revise, keepShapes: true. The builder refuses an invalid operation when you call .add() or .revise(), before anything is queued.
Wref constraints are enforced server-side. An untyped wref accepts shapes and shaped things. A typed constraint passes when the named Shape is in the target’s Shape set: a Shape the target declares, or one reached from those through composition. Any one match is enough; no Shape in the set is primary. A shapeless thing and a Shape definition never pass: alice (a Thing declaring the Player Shape) can satisfy wref<Player>, while the Shape hub/shapes/Player itself cannot. OperationBuilder validates field types and most local constraints, but it cannot prove a target’s Shape set until the operation reaches the server.
Version preconditions
Section titled “Version preconditions”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.
Read leases
Section titled “Read leases”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", "alice", { ttlMs: 5000 });
await client.commit.apply("acme", "world", "update score", [ { operation: "revise", name: "alice", shapes: ["Player"], 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", "alice", leased.lease.id).leased = client.thing.get_with_lease("acme", "world", "alice", ttl_ms=5000)
world.apply("update score", [ Revise(name="alice", shapes=["Player"], data={"score": 2}, lease_id=leased.lease.id),])# The lease auto-releases on a successful or no-op write. To bail out without writing,# call client.thing.release_lease("acme", "world", "alice", leased.lease.id).Lease acquisition is on the unbound namespace, so it takes the org and repo explicitly even when you hold a repository handle.
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 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.
Repository sequence acknowledgement
Section titled “Repository sequence acknowledgement”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: 'cave', shapes: ['Location'], 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)result = world.apply("seed cave", [ Add(name="cave", shapes=["Location"], data={"x": 0, "y": 0}),])event = result.receipts[0].eventprint(event.repo_seq if event else None) # e.g. "42"result = batch.commit()event = result.receipts[0].eventprint(event.repo_seq if event else None)event is None on a no-event receipt, so guard it before reading
repo_seq — there is no optional-chaining shortcut.
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.reason | Additional fields | When it appears | Python support |
|---|---|---|---|
"expected_version_mismatch" | expectedVersion: number; currentVersion: number | Version precondition failed | Yes — expected_version, current_version |
"lease_held" | leaseExpiresAt: string (ISO 8601) | Another caller holds the lease and the supplied leaseId does not match | Yes — lease_expires_at |
"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 | Yes — issues |
"rate_limit_reset" | retryAfterSeconds: number; resetAt: string (ISO 8601) | The operation was rejected because a rate limit was hit; retry after the indicated interval | Yes — retry_after_seconds, reset_at |
"cursor_fence_unavailable" | cause: "not_ready" | "below_floor" | "above_head"; retryFromStart: true | The streaming cursor fence could not be satisfied; reconstruct the full operation source and retry from the beginning | TypeScript only — Python SDK does not decode this arm; details falls back to UnknownErrorDetails |
"dependency_failed" | producerOpIndexes: number[] | One or more producer operations that this operation depended on failed; the field lists the opIndex values of the failed producers | TypeScript only — Python SDK does not decode this arm; details falls back to UnknownErrorDetails |
"name_taken" | name: string; takenBy?: { durableId: string } (taken_by in Python, typed as NameTakenBy) | The requested name is already in use by another thing; takenBy identifies the holder by durable id when disclosed — get it for the holder’s Shapes, since a Thing can have several Shapes or none (Python SDK ships a dedicated NameTaken model for this arm, with taken_by typed as NameTakenBy) | Yes |
"target_removed" | targetDurableId: string; fieldPath?: string | A new reference or dependency names a thing that has been permanently removed; targetDurableId identifies it, and fieldPath is present when the reference came from a field in the operation body | Yes — target_durable_id, field_path, via the TargetRemoved model |
"target_redacted" | targetDurableId: string; fieldPath?: string | A write or new reference names a thing that has been redacted; targetDurableId identifies it, and fieldPath is present when the reference came from a field in the operation body | TypeScript only — Python SDK does not decode this arm; details falls back to UnknownErrorDetails |
A failed operation may carry details.reason: "dependency_failed" with producerOpIndexes, naming the failed operations it depended on, present when an operation depended on at most 16 failed operations; its code and message are unchanged (for example, a stream operation dropped with a refused add still reports CONFLICT). This arm is TypeScript-only; Python callers receive UnknownErrorDetails for this reason.
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})`) } else if (details.reason === 'cursor_fence_unavailable') { console.warn(`Cursor fence unavailable (cause: ${details.cause}) — reconstruct the operation source and retry from the start`) } else if (details.reason === 'dependency_failed') { console.error(`Operation depends on failed producers at indexes: ${details.producerOpIndexes.join(', ')}`) } else if (details.reason === 'name_taken') { console.error(`Name already taken: ${details.name}` + (details.takenBy ? ` (held by id=${details.takenBy.durableId})` : '')) } else if (details.reason === 'target_removed') { console.error(`References a permanently removed thing: ${details.targetDurableId}` + (details.fieldPath ? ` (field: ${details.fieldPath})` : '')) } else if (details.reason === 'target_redacted') { console.error(`References a redacted thing: ${details.targetDurableId}` + (details.fieldPath ? ` (field: ${details.fieldPath})` : '')) } } }}for receipt in receipts: for op in receipt.operations: if op.status == "failed" and op.error and op.error.details: details = op.error.details if details.reason == "validation_failed": for issue in details.issues: print(f"Validation error at {issue.path}: {issue.message}") elif details.reason == "rate_limit_reset": print( f"Rate limited — retry after {details.retry_after_seconds}s " f"(resets at {details.reset_at})" ) elif details.reason == "name_taken": # Python SDK provides a dedicated NameTaken model for this arm; # taken_by is a NameTakenBy object with .durable_id. msg = f"Name already taken: {details.name}" if details.taken_by: msg += f" (held by id={details.taken_by.durable_id})" print(msg) elif details.reason == "target_removed": # Python SDK provides a dedicated TargetRemoved model for this arm. msg = f"References a permanently removed thing: {details.target_durable_id}" if details.field_path: msg += f" (field: {details.field_path})" print(msg) else: # Unrecognized reasons — including cursor_fence_unavailable, # dependency_failed, and target_redacted — are not decoded by # the Python SDK and fall back to UnknownErrorDetails. Inspect # details.reason for the raw reason string. print(f"Operation error: {details.reason}")Warnings are informational — a result can still be applied or no-op’d while carrying them. The warnings object is additive and can carry four kinds at once: undeclaredFields (top-level fields in the submitted data that the target shape does not declare), coalescedWrefs (optional wref? fields whose resolver outcome was thing_absent and was coalesced to null — each entry carries fieldPath, wref, and reason), deprecations (the write used a shape scheduled for removal — today only an add or revise of a Pair collection — each entry carries shape, message, and removalMilestone), and ignoredVersionPins (an authored @vN pin on an identity-bound wref field, dropped because an identity binding follows the thing rather than a fixed version — each entry carries fieldPath, wref, and binding). Missing shapes and every other failure remain hard errors. undeclaredFields and coalescedWrefs report truncation (undeclaredFieldsTruncated / coalescedWrefsTruncated) with a full count when the list is capped; ignoredVersionPins is capped at the same 500-entry limit and deduped by field path and wref, but carries no truncation flag or total. 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.