LaserData Cloud
Laser SDK

State

Keyed state, with branches.

Point reads, compare-and-swap, and TTLs live next to the log. Forks create copy-on-write data branches you can promote or discard.

Built for

Sessions, feature flags, what-if simulations

How it works

laser.kv(namespace) scopes a keyed store. Each key-value write is also a log record. Overwriting a key does not discard its history.

  • Write - set(key) opens a builder: .json(value) or .payload(bytes) encodes it, .ttl(duration) attaches an expiry, .send() commits it.
  • Read - get(key) returns raw bytes, get_typed(key) decodes into your type.

Compare-and-set guards a write against a stale read:

  • getEntry(key) returns the current value with its version.
  • A follow-up write with expectVersion(version) only lands if no other writer touched the key in between.
  • expect_absent() is the CAS write that must be the first write to a key.

Use compare-and-set for ledgers and counters that must reject concurrent stale writes.

Forks are copy-on-write branches over materialized rows. They sit beside KV, but they are not an implicit mode for kv.set(..). Two kinds are chosen at creation:

  • Continuous (the default) - a live branch that keeps seeing new trunk appends while carrying its own writes.
  • Severed (.severed()) - a frozen snapshot at the trunk's current offsets. Later trunk appends stay hidden. .tables([...]) narrows a severed snapshot to specific tables, an empty list captures every table.

Then the lifecycle:

  • fork(id).create() opens the branch. .parent(id) records lineage for audit, while the fork still branches off the trunk.
  • put_row(table, partition, offset) writes one speculative row at an exact source coordinate. Add queryable columns with .field(..), or attach a payload, embedding, metadata, or tombstone. TypeScript spells it putRow.
  • query(index).fork(id) reads the overlay. Ordinary KV reads and trunk queries never see speculative rows before promotion.
  • promote() merges the fork's writes back onto the trunk and returns how many rows were applied.
  • squash() discards an already-open fork instead of promoting it.
  • laser.forks() lists every open fork with its metadata.

Promotion applies the speculative rows to the trunk. It does not redirect ordinary KV writes into a hidden branch, so write fork data through the fork handle explicitly.

State requires laser-plane in Laser Stack or LaserData Cloud. KV and forks have separate capability flags.

Quick example

const kv = laser.kv("profiles")
const key = new TextEncoder().encode("user:42")
await kv.set(key).json({ plan: "pro" }).ttl(86_400_000_000n).send()
const profile = await kv.get(key)

// compare-and-swap: only lands if the version still matches
const entry = await kv.getEntry(key)
if (entry === undefined) throw new Error("profile vanished")
await kv
  .set(key)
  .json({ plan: "enterprise" })
  .expectVersion(entry.version)
  .commit()

// fork: a git-like branch of the same state
const fork = laser.fork("experiment-1")
await fork.squash()
await fork.create().severed().tables(["profiles"]).send()
await fork
  .putRow("profiles", 0, 0n)
  .field("plan", "enterprise-preview")
  .send()
const applied = await fork.promote()
let kv = laser.kv("profiles");
kv.set("user:42")
    .json(&profile)?
    .ttl(Duration::from_secs(86_400))
    .send()
    .await?;
let profile = kv.get_typed::<Profile>("user:42").await?;

// compare-and-swap: only lands if the version still matches
let entry = kv.get_entry("user:42").await?.expect("just written");
kv.set("user:42")
    .json(&upgraded)?
    .expect_version(entry.version)
    .commit()
    .await?;

// fork: a git-like branch of the same state
let fork = laser.fork("experiment-1");
fork.squash().await?;
fork.create().severed().tables(["profiles"]).send().await?;
fork.put_row("profiles", 0, 0)
    .field("plan", "enterprise-preview")
    .send()
    .await?;
let applied = fork.promote().await?;
store = laser.kv("profiles")
await store.set("user:42").json({"plan": "pro"}).ttl(86_400).send()
profile = await store.get_typed("user:42")

# compare-and-swap: only lands if the version still matches
entry = await store.get_entry("user:42")
await store.set("user:42").json({"plan": "enterprise"}).expect_version(
    entry.version
).commit()

# fork: a git-like branch of the same state
fork = laser.fork("experiment-1")
await fork.squash()
await fork.create(severed=True, tables=["profiles"])
await fork.put_row("profiles", 0, 0).field(
    "plan", "enterprise-preview"
).send()
applied = await fork.promote()

TypeScript's TTL is microseconds as a bigint. Rust's is a Duration. Python's is plain seconds. Same knob, different unit per language.

Full runnable example: Rust · Python · TypeScript

Beyond get and set

The store is a full keyed surface, not a two-verb cache. All of it exists in all three languages (snake_case in Rust/Python, camelCase in TypeScript):

  • delete(key) removes an entry and tells you whether a live one existed. exists(key) returns the entry's metadata without transferring its value.
  • expire(key, ttl) sets or refreshes an expiry in place without rewriting the value. Passing no TTL clears the expiry. The version comes back unchanged.
  • patch(key, patch) applies a merge patch to a structured value without transferring the whole object and returns the new version. The patch bytes are codec-specific, a JSON merge patch over a JSON value for instance.
  • copy_to(key, to_key) / move_to(key, to_key) duplicate or rename under the same namespace, into_namespace(ns) retargets either across namespaces.
  • get_many(keys) batches point reads. delete_many() is a builder: .prefix(p), .range(start, end), or .key_contains(s) selects, .send() returns how many entries went.
  • scan() pages through a namespace: the same .prefix/.range/.key_contains selectors plus .limit(n) and .cursor(c) for pagination, ending in .fetch() for a page or .entries() for the values.
  • laser.kv_namespaces() lists every namespace on the deployment.

Writes have two finishers: .send() fires and forgets the version, .commit() returns the new version number, which is what you feed the next expect_version(..).

Locks and fenced writes

For state that must have at most one effective writer, the store pairs an advisory lease with a fenced compare-and-swap:

  • lease(key, ttl) acquires a bounded-TTL distributed lock and returns a Lease carrying a fencing token and the TTL the store actually granted.
  • cas_fenced(key, fence_key, token) starts a write that applies only while the fence's sequence still equals your token. A zombie holder resuming with an older token is rejected even if its lease was presumed lost, which is exactly the failure a plain lock cannot defend against.
  • release(key, token) gives the lock back early instead of waiting out the TTL.
const lock = utf8("ledger:lock")
const total = utf8("ledger:total")
const lease = await kv.lease(lock, 10_000_000n)

await kv
  .casFenced(total, lock, lease.token)
  .json(newTotal)
  .expectVersion(entry.version)
  .commit()

await kv.release(lock, lease.token)
let lease = kv.lease("ledger:lock", Duration::from_secs(10)).await?;

kv.cas_fenced("ledger:total", "ledger:lock", lease.token)
    .json(&new_total)?
    .expect_version(entry.version)
    .commit()
    .await?;

kv.release("ledger:lock", lease.token).await?;
token, _granted_ttl = await kv.lease("ledger:lock", 10)

await kv.cas_fenced(
    "ledger:total",
    "ledger:lock",
    token,
    new_total,
    expect_version=entry.version,
)

await kv.release("ledger:lock", token)

Key operations

VerbWhat it does
kv(namespace)Scope a keyed store
set(key).json(v).ttl(d).send()Write a value with an optional expiry
set(..).commit()Same write, returns the new version for a later CAS
get(key) / get_typed(key)Read a value, raw or decoded
getEntry(key)Read a value with its current version, for CAS
expectVersion(v) / expect_absent()Compare-and-set guards
delete(key) / exists(key) / expire(key, ttl)Remove, probe metadata, refresh expiry in place
patch(key, patch)Merge-patch a structured value without a full rewrite
get_many(..) / delete_many() / scan()Batch reads, selector deletes, paged iteration
lease(key, ttl) / cas_fenced(..) / release(..)Advisory lock plus fenced CAS, see above
fork(id).create()Open a copy-on-write branch, .severed() / .tables([...]) to snapshot
put_row(table, partition, offset)Write one speculative row, TypeScript: putRow
query(index).fork(id)Query the fork overlay without changing the trunk
promote()Merge a fork's writes onto the trunk
squash()Discard an already-open fork

Running it

State runs on Laser Stack and LaserData Cloud. The example checks KV and fork capabilities separately.

On this page