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 oldsetentries. 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.NumberFromStringfor 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.payloadSchemaBinaryconst 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 } }Compaction
compaction
unstableRegisters 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
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
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:
setreplaces the whole row, creating it if it is missing.patchupdates an existing row and does nothing if the row is missing. Upserting can resurrect removed rows on only some replicas.removeis 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
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;}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;}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>>Schema of a row event's payload, derived from the row schema.
Details
setcarries the full row, validated by the row schema.patchcarries the row key and the fields to write. An absent field is not written. The key itself cannot be patched.removecarries 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;}>]>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
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>]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]> }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]> }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> : stringRequired 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