effect-cqrs 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +326 -2
  3. package/dist/Aggregate.d.ts +127 -0
  4. package/dist/Aggregate.d.ts.map +1 -0
  5. package/dist/Aggregate.js +59 -0
  6. package/dist/Aggregate.js.map +1 -0
  7. package/dist/AggregateService.d.ts +154 -0
  8. package/dist/AggregateService.d.ts.map +1 -0
  9. package/dist/AggregateService.js +407 -0
  10. package/dist/AggregateService.js.map +1 -0
  11. package/dist/Causation.d.ts +34 -0
  12. package/dist/Causation.d.ts.map +1 -0
  13. package/dist/Causation.js +32 -0
  14. package/dist/Causation.js.map +1 -0
  15. package/dist/Checkpoints.d.ts +19 -0
  16. package/dist/Checkpoints.d.ts.map +1 -0
  17. package/dist/Checkpoints.js +18 -0
  18. package/dist/Checkpoints.js.map +1 -0
  19. package/dist/EventStore.d.ts +132 -0
  20. package/dist/EventStore.d.ts.map +1 -0
  21. package/dist/EventStore.js +156 -0
  22. package/dist/EventStore.js.map +1 -0
  23. package/dist/EventVersions.d.ts +64 -0
  24. package/dist/EventVersions.d.ts.map +1 -0
  25. package/dist/EventVersions.js +55 -0
  26. package/dist/EventVersions.js.map +1 -0
  27. package/dist/Fixture.d.ts +106 -0
  28. package/dist/Fixture.d.ts.map +1 -0
  29. package/dist/Fixture.js +149 -0
  30. package/dist/Fixture.js.map +1 -0
  31. package/dist/Metadata.d.ts +42 -0
  32. package/dist/Metadata.d.ts.map +1 -0
  33. package/dist/Metadata.js +39 -0
  34. package/dist/Metadata.js.map +1 -0
  35. package/dist/PostgresEventStore.d.ts +30 -0
  36. package/dist/PostgresEventStore.d.ts.map +1 -0
  37. package/dist/PostgresEventStore.js +153 -0
  38. package/dist/PostgresEventStore.js.map +1 -0
  39. package/dist/Saga.d.ts +105 -0
  40. package/dist/Saga.d.ts.map +1 -0
  41. package/dist/Saga.js +97 -0
  42. package/dist/Saga.js.map +1 -0
  43. package/dist/SqliteEventStore.d.ts +25 -0
  44. package/dist/SqliteEventStore.d.ts.map +1 -0
  45. package/dist/SqliteEventStore.js +142 -0
  46. package/dist/SqliteEventStore.js.map +1 -0
  47. package/dist/ViewModel.d.ts +90 -0
  48. package/dist/ViewModel.d.ts.map +1 -0
  49. package/dist/ViewModel.js +172 -0
  50. package/dist/ViewModel.js.map +1 -0
  51. package/dist/index.d.ts +13 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +13 -0
  54. package/dist/index.js.map +1 -0
  55. package/package.json +89 -3
  56. package/src/Aggregate.ts +205 -0
  57. package/src/AggregateService.ts +705 -0
  58. package/src/Causation.ts +48 -0
  59. package/src/Checkpoints.ts +23 -0
  60. package/src/EventStore.ts +270 -0
  61. package/src/EventVersions.ts +104 -0
  62. package/src/Fixture.ts +298 -0
  63. package/src/Metadata.ts +52 -0
  64. package/src/PostgresEventStore.ts +199 -0
  65. package/src/Saga.ts +176 -0
  66. package/src/SqliteEventStore.ts +196 -0
  67. package/src/ViewModel.ts +350 -0
  68. package/src/index.ts +12 -0
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Why an event was recorded. Every event carries two ids:
3
+ *
4
+ * - `correlationId` is shared by everything that follows from one original
5
+ * request, across aggregates and view models.
6
+ * - `causationId` names what directly caused the command that recorded it: the
7
+ * request, or the event a view model or saga was reacting to.
8
+ *
9
+ * They travel in the fiber's context rather than in payloads. A command run
10
+ * outside any causation starts a new correlation with a fresh id. Inside a view
11
+ * model handler the event being handled is the cause, so a chain of reactions
12
+ * can be drawn as a tree without passing ids around by hand.
13
+ */
14
+ import { Context, Effect, Function } from "effect"
15
+ import { eventId, type StoredEvent } from "./EventStore.ts"
16
+
17
+ export interface Causation {
18
+ readonly correlationId: string
19
+ readonly causationId: string
20
+ }
21
+
22
+ /** The causation of commands run in this fiber, `undefined` outside any. */
23
+ export const CurrentCausation = Context.Reference<Causation | undefined>("effect-cqrs/Causation", {
24
+ defaultValue: () => undefined
25
+ })
26
+
27
+ /** Runs an effect with this causation, for instance the id of an incoming HTTP request. */
28
+ export const withCausation: {
29
+ (causation: Causation): <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
30
+ <A, E, R>(effect: Effect.Effect<A, E, R>, causation: Causation): Effect.Effect<A, E, R>
31
+ } = Function.dual(
32
+ 2,
33
+ <A, E, R>(effect: Effect.Effect<A, E, R>, causation: Causation) =>
34
+ Effect.provideService(effect, CurrentCausation, causation)
35
+ )
36
+
37
+ /** The causation of commands run in reaction to a stored event. */
38
+ export const fromEvent = (event: StoredEvent): Causation => {
39
+ const id = eventId(event)
40
+ return { correlationId: event.correlationId ?? id, causationId: id }
41
+ }
42
+
43
+ /**
44
+ * Runs an effect in reaction to a stored event, so commands it runs record that
45
+ * event as their cause. View models do this for their handlers already.
46
+ */
47
+ export const causedBy = (event: StoredEvent) => <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
48
+ Effect.provideService(effect, CurrentCausation, fromEvent(event))
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Remembers how far each view model has read.
3
+ *
4
+ * A checkpoint has to live as long as the view model it tracks: pair an
5
+ * in-memory view model with {@link layerMemory} so it rebuilds from the event
6
+ * store on restart, and a durable one with a durable checkpoint store.
7
+ */
8
+ import { Context, Effect, Layer } from "effect"
9
+ import type { EventStoreError } from "./EventStore.ts"
10
+
11
+ export class Checkpoints extends Context.Service<Checkpoints, {
12
+ /** The last processed position, `0` when the view model never ran. */
13
+ get(viewModel: string): Effect.Effect<number, EventStoreError>
14
+ set(viewModel: string, position: number): Effect.Effect<void, EventStoreError>
15
+ }>()("effect-cqrs/Checkpoints") {}
16
+
17
+ export const layerMemory: Layer.Layer<Checkpoints> = Layer.sync(Checkpoints, () => {
18
+ const positions = new Map<string, number>()
19
+ return Checkpoints.of({
20
+ get: (viewModel) => Effect.sync(() => positions.get(viewModel) ?? 0),
21
+ set: (viewModel, position) => Effect.sync(() => void positions.set(viewModel, position))
22
+ })
23
+ })
@@ -0,0 +1,270 @@
1
+ /**
2
+ * The append-only log every aggregate and view model is built on.
3
+ *
4
+ * Each event belongs to a stream, identified by aggregate name and id, and has
5
+ * two coordinates: a `version` that orders it inside its stream and a `position`
6
+ * that orders it across the whole store. Writers use the version for optimistic
7
+ * concurrency, readers use the position as a durable cursor.
8
+ */
9
+ import { Context, DateTime, Effect, Layer, Option, Ref, Schema, Stream, SubscriptionRef } from "effect"
10
+
11
+ export class EventStoreError extends Schema.TaggedError<EventStoreError>()("EventStoreError", {
12
+ operation: Schema.String,
13
+ cause: Schema.Defect()
14
+ }) {}
15
+
16
+ export class ConcurrencyConflict extends Schema.TaggedError<ConcurrencyConflict>()("ConcurrencyConflict", {
17
+ aggregateName: Schema.String,
18
+ aggregateId: Schema.String,
19
+ expectedVersion: Schema.Int
20
+ }) {}
21
+
22
+ export const StoredEvent = Schema.Struct({
23
+ position: Schema.Int,
24
+ aggregateName: Schema.String,
25
+ aggregateId: Schema.String,
26
+ version: Schema.Int,
27
+ type: Schema.String,
28
+ /** The version of the event's schema the payload was written with. Events from before versions existed are `1`. */
29
+ schemaVersion: Schema.Int,
30
+ payload: Schema.Json,
31
+ timestamp: Schema.DateTimeUtcFromMillis,
32
+ /** Shared by everything that follows from one original request. */
33
+ correlationId: Schema.NullOr(Schema.String),
34
+ /** What directly caused the command that recorded the event: a request, a command or another event. */
35
+ causationId: Schema.NullOr(Schema.String),
36
+ /** What the command carried beside its payload, see `Metadata`. Events from before metadata existed have `{}`. */
37
+ metadata: Schema.JsonObject
38
+ })
39
+ export type StoredEvent = typeof StoredEvent.Type
40
+
41
+ export interface NewEvent {
42
+ readonly type: string
43
+ readonly payload: Schema.Json
44
+ /** Defaults to `1`. */
45
+ readonly schemaVersion?: number | undefined
46
+ }
47
+
48
+ export interface AppendInput {
49
+ readonly aggregateName: string
50
+ readonly aggregateId: string
51
+ /** The stream version the writer based its decision on, `0` for a new stream. */
52
+ readonly expectedVersion: number
53
+ readonly events: ReadonlyArray<NewEvent>
54
+ readonly correlationId?: string | undefined
55
+ readonly causationId?: string | undefined
56
+ /** Recorded on every event of the append. Defaults to `{}`. */
57
+ readonly metadata?: Schema.JsonObject | undefined
58
+ }
59
+
60
+ /**
61
+ * Identifies an event wherever it is stored, as the `causationId` of what it causes:
62
+ * `account/alice@4`. Name and id are URI-encoded, so a `/` or `@` inside one can't
63
+ * make two events look alike.
64
+ */
65
+ export const eventId = (event: Pick<StoredEvent, "aggregateName" | "aggregateId" | "version">): string =>
66
+ `${encodeURIComponent(event.aggregateName)}/${encodeURIComponent(event.aggregateId)}@${event.version}`
67
+
68
+ /** The columns every adapter stores for a new event, with the defaults filled in. */
69
+ export const toStored = (
70
+ input: AppendInput,
71
+ event: NewEvent,
72
+ index: number,
73
+ position: number,
74
+ timestamp: DateTime.Utc
75
+ ): StoredEvent => ({
76
+ position,
77
+ aggregateName: input.aggregateName,
78
+ aggregateId: input.aggregateId,
79
+ version: input.expectedVersion + index + 1,
80
+ type: event.type,
81
+ schemaVersion: event.schemaVersion ?? 1,
82
+ payload: event.payload,
83
+ timestamp,
84
+ correlationId: input.correlationId ?? null,
85
+ causationId: input.causationId ?? null,
86
+ metadata: input.metadata ?? {}
87
+ })
88
+
89
+ /**
90
+ * Selects events by stream, type and time. Omitted fields match everything.
91
+ * `startTime` is inclusive and `finishTime` is exclusive, so adding one
92
+ * millisecond to a history timestamp selects up to and including that event.
93
+ */
94
+ export interface EventFilter {
95
+ readonly aggregateNames?: ReadonlyArray<string> | undefined
96
+ readonly aggregateIds?: ReadonlyArray<string> | undefined
97
+ readonly eventTypes?: ReadonlyArray<string> | undefined
98
+ readonly startTime?: DateTime.Utc | undefined
99
+ readonly finishTime?: DateTime.Utc | undefined
100
+ readonly afterPosition?: number | undefined
101
+ /**
102
+ * For a filter on one stream: where to continue in it. Versions follow the stream's
103
+ * own index, so resuming there costs the same however far the log has moved on.
104
+ */
105
+ readonly afterVersion?: number | undefined
106
+ }
107
+
108
+ /** A filter on exactly one stream, which a store can read through that stream's index. */
109
+ export const isOneStream = ({ aggregateIds, aggregateNames }: EventFilter): boolean =>
110
+ aggregateNames?.length === 1 && aggregateIds?.length === 1
111
+
112
+ export class EventStore extends Context.Service<EventStore, {
113
+ /** Appends atomically, failing if the stream moved past `expectedVersion`. */
114
+ append(input: AppendInput): Effect.Effect<ReadonlyArray<StoredEvent>, ConcurrencyConflict | EventStoreError>
115
+ /** Reads the events stored so far, ordered by position, then completes. */
116
+ read(filter?: EventFilter): Stream.Stream<StoredEvent, EventStoreError>
117
+ /** Reads the events stored so far, then keeps following new ones. Never completes. */
118
+ subscribe(filter?: EventFilter): Stream.Stream<StoredEvent, EventStoreError>
119
+ /** The position of the latest event, `0` when the store is empty. */
120
+ readonly head: Effect.Effect<number, EventStoreError>
121
+ /**
122
+ * Emits the current latest position, then each newly appended one. Consumers
123
+ * can treat an element as "there may be something new": appends from other
124
+ * processes are not observed.
125
+ */
126
+ readonly changes: Stream.Stream<number>
127
+ }>()("effect-cqrs/EventStore") {}
128
+
129
+ /** What a storage adapter implements. {@link make} builds the rest on top. */
130
+ export interface Backend {
131
+ readonly append: EventStore["Service"]["append"]
132
+ readonly readBatch: (
133
+ filter: EventFilter,
134
+ afterPosition: number,
135
+ limit: number
136
+ ) => Effect.Effect<ReadonlyArray<StoredEvent>, EventStoreError>
137
+ readonly head: Effect.Effect<number, EventStoreError>
138
+ }
139
+
140
+ export const make = Effect.fnUntraced(function*(backend: Backend, options?: { readonly batchSize?: number }) {
141
+ const batchSize = options?.batchSize ?? 500
142
+ const latest = yield* SubscriptionRef.make(yield* backend.head)
143
+
144
+ const append = Effect.fn("EventStore.append")((input: AppendInput) =>
145
+ backend.append(input).pipe(
146
+ Effect.tap((stored) => {
147
+ const last = stored.at(-1)
148
+ return last === undefined
149
+ ? Effect.void
150
+ : SubscriptionRef.update(latest, (position) => Math.max(position, last.position))
151
+ })
152
+ )
153
+ )
154
+
155
+ const read = (filter: EventFilter = {}) =>
156
+ Stream.paginate(
157
+ { afterPosition: filter.afterPosition ?? 0, afterVersion: filter.afterVersion ?? 0 },
158
+ ({ afterPosition, afterVersion }) =>
159
+ backend.readBatch({ ...filter, afterVersion }, afterPosition, batchSize).pipe(
160
+ Effect.map((events) => {
161
+ const last = events.at(-1)
162
+ return [
163
+ events,
164
+ last === undefined || events.length < batchSize
165
+ ? Option.none()
166
+ : Option.some({ afterPosition: last.position, afterVersion: last.version })
167
+ ] as const
168
+ })
169
+ )
170
+ )
171
+
172
+ const subscribe = (filter: EventFilter = {}) =>
173
+ Stream.unwrap(Effect.gen(function*() {
174
+ const cursor = yield* Ref.make({ position: filter.afterPosition ?? 0, version: filter.afterVersion ?? 0 })
175
+ return SubscriptionRef.changes(latest).pipe(
176
+ Stream.filterEffect((position) => Effect.map(Ref.get(cursor), (seen) => position > seen.position)),
177
+ Stream.flatMap((position) =>
178
+ Stream.unwrap(
179
+ Effect.map(Ref.get(cursor), (seen) =>
180
+ read({ ...filter, afterPosition: seen.position, afterVersion: seen.version }))
181
+ ).pipe(
182
+ Stream.tap((event) => Ref.set(cursor, { position: event.position, version: event.version })),
183
+ // Events the filter skipped must not trigger another read.
184
+ Stream.ensuring(
185
+ Ref.update(cursor, (seen) => seen.position >= position ? seen : { ...seen, position })
186
+ )
187
+ )
188
+ )
189
+ )
190
+ }))
191
+
192
+ return EventStore.of({
193
+ append,
194
+ read,
195
+ subscribe,
196
+ head: backend.head,
197
+ changes: SubscriptionRef.changes(latest)
198
+ })
199
+ })
200
+
201
+ const matches = (filter: EventFilter, event: StoredEvent): boolean =>
202
+ (filter.aggregateNames?.includes(event.aggregateName) ?? true) &&
203
+ (filter.aggregateIds?.includes(event.aggregateId) ?? true) &&
204
+ (filter.eventTypes?.includes(event.type) ?? true) &&
205
+ (filter.startTime === undefined || !DateTime.isLessThan(event.timestamp, filter.startTime)) &&
206
+ (filter.finishTime === undefined || DateTime.isLessThan(event.timestamp, filter.finishTime))
207
+
208
+ /** The index of the first event past `position`, in events sorted by position. */
209
+ const firstAfter = (events: ReadonlyArray<StoredEvent>, position: number): number => {
210
+ let low = 0
211
+ let high = events.length
212
+ while (low < high) {
213
+ const middle = (low + high) >>> 1
214
+ if (events[middle]!.position <= position) low = middle + 1
215
+ else high = middle
216
+ }
217
+ return low
218
+ }
219
+
220
+ const streamKey = (aggregateName: string, aggregateId: string) => JSON.stringify([aggregateName, aggregateId])
221
+
222
+ export const makeMemory: Effect.Effect<EventStore["Service"], EventStoreError> = Effect.gen(function*() {
223
+ const events: Array<StoredEvent> = []
224
+ const streams = new Map<string, Array<StoredEvent>>()
225
+
226
+ // Every command reads one stream, which shouldn't cost a walk through the whole log.
227
+ const candidates = (filter: EventFilter): ReadonlyArray<StoredEvent> =>
228
+ isOneStream(filter)
229
+ ? streams.get(streamKey(filter.aggregateNames![0]!, filter.aggregateIds![0]!)) ?? []
230
+ : events
231
+
232
+ return yield* make({
233
+ append: Effect.fnUntraced(function*(input) {
234
+ const timestamp = yield* DateTime.now
235
+ const key = streamKey(input.aggregateName, input.aggregateId)
236
+ const stream = streams.get(key) ?? []
237
+ const version = stream.length
238
+ if (version !== input.expectedVersion) {
239
+ return yield* new ConcurrencyConflict({
240
+ aggregateName: input.aggregateName,
241
+ aggregateId: input.aggregateId,
242
+ expectedVersion: input.expectedVersion
243
+ })
244
+ }
245
+ const stored = input.events.map((event, index) =>
246
+ toStored(input, event, index, events.length + index + 1, timestamp)
247
+ )
248
+ for (const event of stored) {
249
+ events.push(event)
250
+ stream.push(event)
251
+ }
252
+ streams.set(key, stream)
253
+ return stored
254
+ }),
255
+ readBatch: (filter, afterPosition, limit) =>
256
+ Effect.sync(() => {
257
+ const source = candidates(filter)
258
+ const batch: Array<StoredEvent> = []
259
+ for (let index = firstAfter(source, afterPosition); index < source.length && batch.length < limit; index++) {
260
+ const event = source[index]!
261
+ if (matches(filter, event)) batch.push(event)
262
+ }
263
+ return batch
264
+ }),
265
+ head: Effect.sync(() => events.length)
266
+ })
267
+ })
268
+
269
+ /** Keeps events in process memory. They do not survive a restart. */
270
+ export const layerMemory: Layer.Layer<EventStore, EventStoreError> = Layer.effect(EventStore, makeMemory)
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The older shapes of one event, so that events stored long ago still reach
3
+ * projections and view models in today's shape. The log is never rewritten:
4
+ * old payloads are upcast as they are read.
5
+ *
6
+ * ```ts
7
+ * const AccountOpenedV1 = Schema.Struct({ owner: Schema.String })
8
+ *
9
+ * const Account = Aggregate.make<State>()({
10
+ * name: "account",
11
+ * events: AccountEvent,
12
+ * initial,
13
+ * projection,
14
+ * versions: {
15
+ * AccountOpened: EventVersions.from(AccountOpenedV1).next(
16
+ * AccountEvent.cases.AccountOpened,
17
+ * ({ owner }) => ({ owner, currency: "CHF" })
18
+ * )
19
+ * }
20
+ * })
21
+ * ```
22
+ *
23
+ * The first schema is version 1, the shape events had before the history was
24
+ * declared, and every `next` adds a version. The last one must be the event's
25
+ * current schema, which is what new events are written with.
26
+ */
27
+ import { Schema, SchemaGetter } from "effect"
28
+
29
+ /** A schema that decodes and encodes without services, as stored payloads need. */
30
+ export type VersionSchema = Schema.Top & {
31
+ readonly DecodingServices: never
32
+ readonly EncodingServices: never
33
+ }
34
+
35
+ /** What an upcast returns: the next version, without the `_tag` the event's name already gives. */
36
+ export type Untagged<T> = T extends { readonly _tag: string } ? Omit<T, "_tag"> : T
37
+
38
+ type Last<Schemas extends ReadonlyArray<VersionSchema>> = Schemas extends readonly [...any, infer L extends VersionSchema]
39
+ ? L
40
+ : VersionSchema
41
+
42
+ export interface EventVersions<Schemas extends ReadonlyArray<VersionSchema>, Latest> {
43
+ /** Oldest first: `schemas[0]` is version 1. */
44
+ readonly schemas: Schemas
45
+ /** `upcasts[i]` turns version `i + 1` into version `i + 2`. */
46
+ readonly upcasts: ReadonlyArray<(previous: any) => unknown>
47
+ /** The type of the last version, which an aggregate checks against its event. */
48
+ readonly "~latest"?: Latest
49
+ /** Adds a version, with the function that turns the previous one into it. */
50
+ next<Next extends VersionSchema>(
51
+ schema: Next,
52
+ upcast: (previous: Latest) => Untagged<Next["Type"]>
53
+ ): EventVersions<readonly [...Schemas, Next], Next["Type"]>
54
+ }
55
+
56
+ /** Any history whose last version is `Latest`. */
57
+ export type Any<Latest = any> = EventVersions<ReadonlyArray<VersionSchema>, Latest>
58
+
59
+ /** The schema of one version, or `never` when the history has no such version. */
60
+ export type SchemaAt<H extends Any, V extends number> = H extends EventVersions<infer Schemas, any>
61
+ ? [never, ...Schemas][V] extends infer S extends VersionSchema ? S : never
62
+ : never
63
+
64
+ const build = <Schemas extends ReadonlyArray<VersionSchema>>(
65
+ schemas: Schemas,
66
+ upcasts: ReadonlyArray<(previous: any) => unknown>
67
+ ): EventVersions<Schemas, Last<Schemas>["Type"]> => ({
68
+ schemas,
69
+ upcasts,
70
+ next: (schema, upcast) => build([...schemas, schema] as const, [...upcasts, upcast]) as any
71
+ })
72
+
73
+ /** Starts a history at version 1. */
74
+ export const from = <V1 extends VersionSchema>(schema: V1): EventVersions<readonly [V1], V1["Type"]> =>
75
+ build([schema] as const, [])
76
+
77
+ /** The version new events of this kind are written with. */
78
+ export const current = (versions: Any | undefined): number => versions?.schemas.length ?? 1
79
+
80
+ /**
81
+ * Builds one decoder per old version, each going all the way to the current one.
82
+ * The chains are composed once with `Schema.decodeTo`, so reading an old event
83
+ * costs one decode however many versions it is behind.
84
+ *
85
+ * `decoders[v - 1]` decodes a JSON payload stored at version `v`. The current
86
+ * version has none, as the aggregate's own schema reads it.
87
+ */
88
+ export const decoders = (tag: string, versions: Any): ReadonlyArray<Schema.Decoder<unknown>> =>
89
+ versions.schemas.slice(0, -1).map((schema, index) =>
90
+ versions.schemas.slice(index + 1).reduce<Schema.Top>(
91
+ (chain, next, step) =>
92
+ chain.pipe(Schema.decodeTo(Schema.toType(next), {
93
+ // Adding the tag lets the last step decode into the tagged event, and a
94
+ // schema in between without one drops it again.
95
+ decode: SchemaGetter.transform((previous) => ({
96
+ ...(versions.upcasts[index + step]!(previous) as object),
97
+ _tag: tag
98
+ })),
99
+ // A payload is only ever written in its current version.
100
+ encode: SchemaGetter.forbidden(() => `${tag} can only be written in its current version`)
101
+ })),
102
+ Schema.toCodecJson(schema)
103
+ ) as Schema.Decoder<unknown>
104
+ )