Write
Append items as one atomic write, retry with a producer position, and make a write conditional on the tail offset.
Append
import { Effect } from "effect";
import { Notes } from "./streams.ts";
const write = Effect.gen(function* () {
const notes = yield* Notes;
const result = yield* notes.append({}, [{ id: "n1", text: "hello" }]);
return result.offset;
});append(params, items, options?) encodes the items and writes them as one atomic append.
| Result | Meaning |
|---|---|
Appended | Written. offset is the new tail and the next read position |
Duplicate | This producer position was already accepted |
| Option | Effect |
|---|---|
close | Closes the stream after the items |
producer | Sends a producer position for safe retries |
expectedOffset | Writes only if the tail equals this offset |
A JSON stream keeps one message for each item. A bytes stream joins the items of one append into one message.
Durable State changes
DurableState.changes makes the change events for one address of a Streamsy.state stream.
import { Effect } from "effect";
import { DurableState } from "@streamsy/core";
import { Workspace } from "./streams.ts";
const createIssue = (offset: string, issue: { id: string; projectId: string; title: string }) =>
Effect.gen(function* () {
const workspace = yield* Workspace;
const params = { workspaceId: "main" };
const facts = DurableState.changes(Workspace.at(params), { offset }, [
DurableState.upsert("issue", issue),
]);
yield* workspace.append(params, facts);
});| Field | Value |
|---|---|
key | The collection key field of an upserted value, or the key given to remove |
offset | The source offset you give |
txid | ${offset}:${index}, unless the change sets it |
DurableState.remove("issue", id) deletes by key. Add { oldValue } to also write old_value. If you give no offset, the events have no offset and no default txid. An upsert or delete can also set timestamp or from. A change to an undeclared collection, or a value that does not match its schema, does not compile.
Control messages
DurableState.changes also takes the State control messages. Make them with StreamAddress.reset(), StreamAddress.snapshotStart() and StreamAddress.snapshotEnd(). Each takes an optional { offset }.
import { Effect } from "effect";
import { DurableState, StreamAddress } from "@streamsy/core";
import { Workspace } from "./streams.ts";
const replaceIssues = (issues: ReadonlyArray<{ id: string; projectId: string; title: string }>) =>
Effect.gen(function* () {
const workspace = yield* Workspace;
const params = { workspaceId: "main" };
const facts = DurableState.changes(Workspace.at(params), {}, [
StreamAddress.reset(),
...issues.map((issue) => DurableState.upsert("issue", issue)),
]);
yield* workspace.append(params, facts);
});A control passes through changes unchanged. With an offset, a control that has no offset of its own gets that offset. Controls do not take a txid, but they count in the index of the default txid of the changes after them.
Close
import { Effect } from "effect";
import { Notes } from "./streams.ts";
const close = Effect.flatMap(Notes, (notes) => notes.append({}, [], { close: true }));Later appends fail with StreamClosed.
Producer retries
A producer position is { producerId, producerEpoch, producerSeq }. Producer.next advances it.
import { Effect } from "effect";
import { Producer } from "@streamsy/core";
import { Notes } from "./streams.ts";
const importOne = (position: Producer.Position) =>
Effect.gen(function* () {
const notes = yield* Notes;
const result = yield* notes.append({}, [{ id: "n3", text: "retried" }], {
producer: position,
});
// Appended and Duplicate both acknowledge this position.
return { result, next: Producer.next(position) };
});Advance the position only after Appended or Duplicate. Send the same bytes on a retry. The server compares positions, not payloads.
| Case | Result |
|---|---|
Next producerSeq | Appended |
Accepted producerSeq again | Duplicate |
Skipped producerSeq | ProducerGap |
Older producerEpoch | StaleEpoch |
producerSeq starts at 0 for each new epoch. For restarts, see the core producer restart contract and PROTOCOL.md section 5.2.1.
Conditional writes
expectedOffset writes only if the tail still equals it. ZERO_OFFSET means the stream must be empty. A miss writes nothing and fails with OffsetMismatch, with expected and actual.
import { Effect, Predicate } from "effect";
import { Orders, apply, initial } from "./orders.ts";
const reserve = (sku: string) =>
Effect.gen(function* () {
const orders = yield* Orders;
const { state, offset } = yield* orders.fold({}, () => initial, apply);
if (state.reserved.has(sku)) return { _tag: "Rejected" as const };
const appended = yield* orders.append({}, [{ type: "reserved", sku }], {
expectedOffset: offset,
});
return { _tag: "Committed" as const, offset: appended.offset };
}).pipe(Effect.retry({ while: Predicate.isTagged("OffsetMismatch"), times: 5 }));fold returns the folded state and the tail it read up to. That tail is the expectedOffset. A competing writer moves the tail, so the append fails and the retry reads again. To give one writer ownership of a stream, use a producer epoch.
The append's AppendFailure<O> type follows its options. Only an append with expectedOffset can fail with OffsetMismatch, and only an append with producer can fail with StaleEpoch or ProducerGap. An append needs at least one item, unless it sets close: true.
expectedOffset is a Streamsy extension to the protocol. See Extensions.
Read, fold, append
decide does the loop above in one call. It folds the stream, calls decide with the state and the tail it read, and appends with that tail as expectedOffset:
import { Effect } from "effect";
import { Orders, apply, initial } from "./orders.ts";
const reserve = (sku: string) =>
Effect.gen(function* () {
const orders = yield* Orders;
return yield* orders.decide(
{},
{
initial: () => initial,
fold: apply,
decide: (state) =>
Effect.succeed(
state.reserved.has(sku)
? { value: "rejected" as const, items: [] }
: { value: "reserved" as const, items: [{ type: "reserved" as const, sku }] },
),
},
);
});What decide does:
- It folds the stream from the start with
initialandfold. - It calls
decidewith the state and the tail it read.decidemust not write and must not change the state. - Empty
itemsreturns at once. There is no append and no tail check. - Otherwise it appends the items with
expectedOffsetset to that tail. - When its own append fails with
OffsetMismatch, it folds only the new items onto the same state and decides again.retrysets the schedule. The default isSchedule.recurs(3). When the schedule ends, it fails with the lastOffsetMismatch. - It does not retry other failures. An
OffsetMismatchthatdecideraises is not retried. A lost reply can hide an append that landed, so a caller that retries the whole call must makedecidesafe against its own write.
It returns { value, offset, appended }. offset is the new tail after a write, or the tail that was read. Streams.decide(address, spec) is the same call on an address.
On a Durable State stream, pass the offset to DurableState.changes, so each change records the offset that the append checks. The issue tracker does this in IssueTracker.mutate.
To project a stream into state and then append, see Read, decide, append.
Errors
AppendFailure<O> includes protocol rejections, StreamsFault, and EncodeFault. create and fork use CreateFailure; remove uses RemoveFailure. Import these types from @streamsy/core.
Caller bugs are defects in toolkit append: InvalidAppendRequest, InvalidEpochSeq, and rejections ruled out by its options. An empty encoded bytes item dies with a RangeError before the write. The protocol writer and HTTP edge keep all append rejections typed.
| Error | When |
|---|---|
StreamClosed | The stream is closed |
OffsetMismatch | The tail is not expectedOffset |
StaleEpoch | A higher producer epoch exists |
ProducerGap | A producer sequence was skipped |
AppendConflict | The content type does not match, or Stream-Seq did not advance |
EncodeFault | An item does not match the codec |
Over HTTP
POST on the stream path appends. It needs append or full access.
| Header | Carries |
|---|---|
Producer-Id, Producer-Epoch, Producer-Seq | The producer position |
Stream-Expected-Offset | The expected tail offset |
The remote Layer sends these headers only when its capabilities allow them. See Remote client.