Skip to content
Effect 4.0 is here. One ecosystem. Zero dependencies.

EventRow unstable

Mutable rows that converge across event-log replicas.

Each row type has one event with set, patch and remove payloads, keyed by one or more of its fields. Pass the result of make to EventGroup.add. Its set, patch and remove constructors return { event, payload }, which spreads into EventLog.write and serves as an item of EventLog.writeMany or an argument of an EventLog.makeClient client.

handler resolves conflicts per top-level field and calls at most one storage callback. Replicas converge to the result of applying entries in id order. compaction drops entries that newer ones shadow.

Details

Replay decodes entries and stored conflicts with the current row schema, which must accept every encoding previously written:

  • New fields must accept absence, for example with Schema.withDecodingDefaultKey. Nullable fields alone reject old set entries. Patches never apply defaults.
  • Removing a field is safe. Old entries decode without it.
  • To change a field's type, keep its encoded type and change its decoded type with a transformation, such as Schema.NumberFromString for a field that used to be a string.
  • Making a field required, renaming it, narrowing its type, changing its encoded type or changing the key breaks old entries. That includes widening a field to a union of its old and new types, because union encodings are tagged. A rename keeps old entries readable only if the field has an explicit SchemaBinary.fieldId.
  • Key-field schemas must decode written values to ones with the same primary key, since replicas derive the key from decoded values.

Conflicts are scoped to one row; parent removal does not resolve concurrent child writes. For cascading deletion, retain a removed-parent marker and delete its children in the parent remove callback. The remove callback writes the marker even when the parent was never stored, since compaction or a newer remove can skip its set, and the marker must not depend on the old row. A parent patch treats a marker as a missing row. Child set and patch callbacks must skip marked parents but allow parents not yet received. Make the parent id part of the child key, as in fields: ["parentId", "id"], so a child cannot move between parents. This converges only if parent deletion is final (no key reuse). Queries must exclude marked parents. Retain markers unless the app defines a safe cutoff for removing them.

Example Syncing grocery rows

import { Effect, Schema } from "effect"
import { EventGroup, EventLog, EventRow } from "effect/eventlog"
const Grocery = EventRow.make("grocery", {
key: "id",
schema: Schema.Struct({
id: Schema.String,
name: Schema.String,
completed: Schema.Boolean
})
})
const Groceries = EventGroup.empty.add(Grocery)
const store = new Map<string, typeof Grocery.schema.Type>()
export const GroceriesLive = EventLog.group(Groceries, (handlers) =>
handlers.handle(
"grocery",
EventRow.handler(Grocery, {
set: (row) => Effect.sync(() => store.set(row.id, row)),
patch: (id, fields) =>
Effect.sync(() => {
const row = store.get(id)
if (row !== undefined) store.set(id, { ...row, ...fields })
}),
remove: (id) => Effect.sync(() => store.delete(id))
})
))
export const complete = Effect.fnUntraced(function*(id: string) {
const log = yield* EventLog.EventLog
yield* log.write({
schema: EventLog.schema(Groceries),
...Grocery.patch(id, { completed: true })
})
})

Example Evolving a row schema

import { Effect, Schema } from "effect"
import { EventGroup, EventRow } from "effect/eventlog"
const ItemV1 = EventRow.make("item", {
key: "id",
schema: Schema.Struct({ id: Schema.String, quantity: Schema.String })
})
// Adds a nullable field and reads the old string quantity as a number.
const ItemV2 = EventRow.make("item", {
key: "id",
schema: Schema.Struct({
id: Schema.String,
quantity: Schema.NumberFromString,
note: Schema.NullOr(Schema.String).pipe(
Schema.withDecodingDefaultKey(Effect.succeed(null))
)
})
})
const v1 = EventGroup.empty.add(ItemV1).events.item.payloadSchemaBinary
const v2 = EventGroup.empty.add(ItemV2).events.item.payloadSchemaBinary
const written = Schema.encodeUnknownSync(v1)(ItemV1.set({ id: "a", quantity: "3" }).payload)
const decoded = Schema.decodeUnknownSync(v2)(written)
decoded // => { _tag: "set", row: { id: "a", quantity: 3, note: null } }
13 exports Since v4.0.6 Source

Compaction

compaction

unstable
Since v4.0.6

Registers a compactor for one row event of a group.

Details

Drops entries superseded within the same remote batch and row by a newer set or remove. A patch is also dropped when newer patches together overwrite all its fields. Kept entries retain their ids and stored conflicts.

A set or remove followed only by patches is kept: without it, the patches would apply to a different row, or to none. Entries that fail to decode are kept.

Leaves other events' compactors unchanged. Register one compactor per row event.

Signature

declare function compaction<Events extends Any, Tag extends string, Fields extends Fields, Key extends string, Error extends Top, KeySchema extends Constraint>(group: EventGroup<Events>, row: EventRow<Tag, Fields, Key, Error, KeySchema>): Layer<never, never, Registry | DecodingServices<Fields> | DecodingServices<{
readonly _tag: tag<"patch">;
readonly fields: Struct<PatchFields<Fields, Key>>;
readonly key: KeySchema;
}>>

Constructors

make

unstable
Since v4.0.6

Defines a row type as a single event.

Details

key names a required field, or declares required fields with an optional derive function. patch, remove and the handler callbacks take the key as typed values: the field's value for a single field, and a struct of the fields for a declaration. Key fields cannot be patched. To move a row, remove it and set it again.

A single-field key must be a DefaultKeyField. Declare other fields with a derive function.

keyOf computes the primary key that conflicts are grouped by: the value of a string single-field key, the result of derive, or else the JSON tuple of the key fields' JSON encodings. -0 and 0 are the same key.

Last-writer-wins applies to top-level fields. Nested values are atomic, so concurrent updates within one nested value do not merge.

error is the error schema of the event. Handler callbacks can fail with it.

Gotchas

Do not compact row events with EventLog.groupCompaction or EventLog.groupBatchCompaction. Compaction runs before stored conflicts are read, so merging row ops loses or misapplies writes. Use compaction, which only drops entries that newer ones shadow.

For multi-row operations, use EventLog.writeMany with one op per row.

Signature

declare function make<Tag extends string, Fields extends Fields, Key extends string = never, Declared extends string = never, Error extends Top = Never>(tag: Tag, options: {
readonly error?: Error;
readonly key: Key | KeyDeclaration<Fields, Declared>;
readonly schema: Struct<Fields>;
}): EventRow<Tag, Fields, [NoInfer<Declared>] extends [never] ? NoInfer<Key> : NoInfer<Declared>, Error, [NoInfer<Declared>] extends [never] ? Fields[NoInfer<Key>] : Struct<{ [K in string]: Fields[K] }>>

Handlers

handler

unstable
Since v4.0.6

Builds the handler for a row event, to pass to handlers.handle.

Details

Resolves newer conflicts in id order and calls at most one callback with typed values and key, never conflicts.

Newer writes take precedence per top-level field. Empty patches and entries with nothing left to write skip the callback but are still journaled and synced. A late set retains newer patches, including when the row is missing.

Decodes stored entry bytes so local writes and replay pass the same values to callbacks, stripping keys outside the schema at any depth. Requires the payload schema's decoding services, plus any services the callbacks require. Callbacks may fail with the event's error type; success is always void.

Gotchas

Convergence requires this callback contract:

  • set replaces the whole row, creating it if it is missing.
  • patch updates an existing row and does nothing if the row is missing. Upserting can resurrect removed rows on only some replicas.
  • remove is idempotent.

Signature

declare function handler<Tag extends string, Fields extends Fields, Key extends string, Error extends Top, KeySchema extends Constraint, RSet = never, RPatch = never, RRemove = never>(row: EventRow<Tag, Fields, Key, Error, KeySchema>, callbacks: {
readonly patch: (key: Type<KeySchema>, fields: View<PatchFields<Fields, Key>, "Type", TypeOptionalKeys<PatchFields<Fields, Key>>, TypeMutableKeys<PatchFields<Fields, Key>>>) => Effect<unknown, Error["Type"], RPatch>;
readonly remove: (key: Type<KeySchema>) => Effect<unknown, Error["Type"], RRemove>;
readonly set: (row: View<Fields, "Type", TypeOptionalKeys<Fields>, TypeMutableKeys<Fields>>, key: Type<KeySchema>) => Effect<unknown, Error["Type"], RSet>;
}): (options: {
readonly conflicts: readonly Array<{
readonly entry: Entry;
readonly payload: {
readonly _tag: "set";
readonly row: View<Fields>;
} | View<{
readonly _tag: tag<"patch">;
readonly fields: Struct<PatchFields<Fields, Key>>;
readonly key: KeySchema;
}, "Type", KeySchema extends {
readonly "~type.optionality": "optional";
} ? "key" : never, KeySchema extends {
readonly "~type.mutability": "mutable";
} ? "key" : never> | View<{
readonly _tag: tag<"remove">;
readonly key: KeySchema;
}, "Type", KeySchema extends {
readonly "~type.optionality": "optional";
} ? "key" : never, KeySchema extends {
readonly "~type.mutability": "mutable";
} ? "key" : never>;
}>;
readonly entry: Entry;
readonly payload: {
readonly _tag: "set";
readonly row: View<Fields>;
} | View<{
readonly _tag: tag<"patch">;
readonly fields: Struct<PatchFields<Fields, Key>>;
readonly key: KeySchema;
}, "Type", KeySchema extends {
readonly "~type.optionality": "optional";
} ? "key" : never, KeySchema extends {
readonly "~type.mutability": "mutable";
} ? "key" : never> | View<{
readonly _tag: tag<"remove">;
readonly key: KeySchema;
}, "Type", KeySchema extends {
readonly "~type.optionality": "optional";
} ? "key" : never, KeySchema extends {
readonly "~type.mutability": "mutable";
} ? "key" : never>;
readonly storeId: StoreId;
}) => Effect<void, Error["Type"], RSet | RPatch | RRemove | DecodingServices<Fields> | DecodingServices<{
readonly _tag: tag<"patch">;
readonly fields: Struct<PatchFields<Fields, Key>>;
readonly key: KeySchema;
}>>

Models

EventRow

interface unstable
Since v4.0.6

A row type that syncs through an EventLog.

Details

Includes EventGroup.add options, the row schema, its key fields and constructors for the events to write. The row key is the event primary key; success is void.

Key is the union of the key fields. KeySchema is the schema of the key in patch and remove payloads and callbacks: the field's schema for a single-field key, and a struct of the key fields for a declared key.

Signature

interface EventRow<out Tag extends string, in out Fields extends Schema.Struct.Fields, in out Key extends keyof Fields & string, in out Error extends Schema.Top = typeof Schema.Never, in out KeySchema extends Schema.Constraint = Fields[Key]> {
readonly error: Error;
readonly keyFields: readonly Array<Key>;
readonly keyOf: (fields: KeyValues<Fields, Key>) => PrimaryKey<KeySchema>;
readonly patch: (key: Type<KeySchema>, fields: View<PatchFields<Fields, Key>, "Type", TypeOptionalKeys<PatchFields<Fields, Key>>, TypeMutableKeys<PatchFields<Fields, Key>>>) => Write<Tag, Fields, Key, KeySchema>;
readonly payload: PayloadSchema<Fields, Key, KeySchema>;
readonly primaryKey: (payload: {
readonly _tag: "set";
readonly row: View<Fields>;
} | View<{
readonly _tag: tag<"patch">;
readonly fields: Struct<PatchFields<Fields, Key>>;
readonly key: KeySchema;
}, "Type", KeySchema extends {
readonly "~type.optionality": "optional";
} ? "key" : never, KeySchema extends {
readonly "~type.mutability": "mutable";
} ? "key" : never> | View<{
readonly _tag: tag<"remove">;
readonly key: KeySchema;
}, "Type", KeySchema extends {
readonly "~type.optionality": "optional";
} ? "key" : never, KeySchema extends {
readonly "~type.mutability": "mutable";
} ? "key" : never>) => string;
readonly remove: (key: Type<KeySchema>) => Write<Tag, Fields, Key, KeySchema>;
readonly schema: Struct<Fields>;
readonly set: (row: View<Fields, "Type", TypeOptionalKeys<Fields>, TypeMutableKeys<Fields>>) => Write<Tag, Fields, Key, KeySchema>;
readonly success: Void;
readonly tag: Tag;
}

KeyDeclaration

type unstable
Since v4.0.6

A row key declared from one or more fields.

Details

The primary key is the result of derive, which must be deterministic and unique per row. Without it, the primary key is the JSON tuple of the fields' JSON encodings, in declaration order, which needs every field to be a DefaultKeyField. Other fields can collide in JSON, such as null and undefined, so they require derive.

Signature

type KeyDeclaration<Fields extends Schema.Struct.Fields, Names extends RequiredField<Fields>> = {
readonly fields: readonly [Names, ...Array<Names>];
} & [Names] extends [DefaultKeyField<Fields>] ? {
readonly derive?: (fields: KeyValues<Fields, Names>) => string;
} : {
readonly derive: (fields: KeyValues<Fields, Names>) => string;
}

Payload

type unstable
Since v4.0.6

Decoded payload of a row event.

Signature

type Payload<Fields extends Schema.Struct.Fields, Key extends keyof Fields, KeySchema extends Schema.Constraint = Fields[Key]> = Schema.Schema.Type<PayloadSchema<Fields, Key, KeySchema>>

PayloadSchema

type unstable
Since v4.0.6

Schema of a row event's payload, derived from the row schema.

Details

  • set carries the full row, validated by the row schema.
  • patch carries the row key and the fields to write. An absent field is not written. The key itself cannot be patched.
  • remove carries the row key.

Signature

type PayloadSchema<Fields extends Schema.Struct.Fields, Key extends keyof Fields, KeySchema extends Schema.Constraint = Fields[Key]> = Schema.Union<readonly [Schema.TaggedStruct<"set", {
readonly row: Schema.Struct<Fields>;
}>, Schema.TaggedStruct<"patch", {
readonly fields: Schema.Struct<PatchFields<Fields, Key>>;
readonly key: KeySchema;
}>, Schema.TaggedStruct<"remove", {
readonly key: KeySchema;
}>]>

Write

type unstable
Since v4.0.6

A row event to write, as returned by the set, patch and remove constructors of a row.

Signature

type Write<Tag extends string, Fields extends Schema.Struct.Fields, Key extends keyof Fields, KeySchema extends Schema.Constraint = Fields[Key]> = {
readonly event: Tag;
readonly payload: Payload<Fields, Key, KeySchema>;
}

Utility Types

DefaultKeyField

type unstable
Since v4.0.6

Required fields whose values are all strings, all numbers or all Uint8Arrays, so their JSON encodings form keys without derive.

Signature

type DefaultKeyField<Fields extends Schema.Struct.Fields> = { [K in RequiredField<Fields>]: [Schema.Schema.Type<Fields[K]>] extends [string] ? K : [Schema.Schema.Type<Fields[K]>] extends [number] ? K : [Schema.Schema.Type<Fields[K]>] extends [Uint8Array] ? K : never }[RequiredField<Fields>]

KeyValues

type unstable
Since v4.0.6

Values of the declared key fields of a row.

Signature

type KeyValues<Fields extends Schema.Struct.Fields, Names extends keyof Fields> = { [K in Names]: Schema.Schema.Type<Fields[K]> }

PatchFields

type unstable
Since v4.0.6

Optional non-key fields of a patch payload.

Signature

type PatchFields<Fields extends Schema.Struct.Fields, Key extends keyof Fields> = { [K in Exclude<keyof Fields, Key>]: Schema.optionalKey<Fields[K]> }

PrimaryKey

type unstable
Since v4.0.6

Primary key type of a row: the key value for a string-typed single-field key, and string otherwise.

Signature

type PrimaryKey<KeySchema extends Schema.Constraint> = Schema.Schema.Type<KeySchema> extends string ? Schema.Schema.Type<KeySchema> : string

RequiredField

type unstable
Since v4.0.6

Required fields that can be part of a declared row key.

Signature

type RequiredField<Fields extends Schema.Struct.Fields> = { [K in keyof Fields]: Fields[K] extends {
readonly "~type.optionality": "optional";
} ? never : K }[keyof Fields] & string