refactor(schema): distinguish published event durability

This commit is contained in:
Kit Langton 2026-06-25 11:35:11 -04:00
commit 95f264e04e
9 changed files with 373 additions and 453 deletions

View file

@ -141,6 +141,7 @@ _Avoid_: Response envelope
- Promise streaming methods return a lazy `AsyncIterable` directly rather than a Promise-wrapped stream object. Iteration opens the connection, `AbortSignal` cancels it, and ending iteration closes the underlying request; the Effect emitter analogously returns `Stream` directly. - Promise streaming methods return a lazy `AsyncIterable` directly rather than a Promise-wrapped stream object. Iteration opens the connection, `AbortSignal` cancels it, and ending iteration closes the underlying request; the Effect emitter analogously returns `Stream` directly.
- Promise SSE connection establishment, declared HTTP failures, and infrastructure failures occur during `AsyncIterable` iteration, beginning with its first `next()` call, rather than during synchronous method construction. - Promise SSE connection establishment, declared HTTP failures, and infrastructure failures occur during `AsyncIterable` iteration, beginning with its first `next()` call, rather than during synchronous method construction.
- Neither generated streaming runtime automatically reconnects after disconnection. Promise `AsyncIterable` and Effect `Stream` fail explicitly; live consumers refresh and resubscribe, while durable sequence-based resume remains explicit composition above the generated client. - Neither generated streaming runtime automatically reconnects after disconnection. Promise `AsyncIterable` and Effect `Stream` fail explicitly; live consumers refresh and resubscribe, while durable sequence-based resume remains explicit composition above the generated client.
- Event definition durability is authoritative for published payloads. Durable definitions publish and decode only with commit metadata (`aggregateID`, `seq`, and `version`); live definitions forbid that metadata. Core's pre-commit payload without an assigned sequence is a separate internal type and never reaches subscribers or projectors.
- Promise client construction is synchronous and network-free. It requires `baseUrl`, defaults to `globalThis.fetch`, accepts client-level headers, and merges them with per-call header overrides. - Promise client construction is synchronous and network-free. It requires `baseUrl`, defaults to `globalThis.fetch`, accepts client-level headers, and merges them with per-call header overrides.
- Effect client construction accepts an explicit `baseUrl` and obtains `HttpClient.HttpClient` from the Effect environment. It does not install fetch or duplicate per-call transport policy; callers transform/provide the client for headers, tracing, retries, recording, and tests, while fiber interruption owns cancellation. - Effect client construction accepts an explicit `baseUrl` and obtains `HttpClient.HttpClient` from the Effect environment. It does not install fetch or duplicate per-call transport policy; callers transform/provide the client for headers, tracing, retries, recording, and tests, while fiber interruption owns cancellation.
- Promise and Effect emitters each own their generated public type modules. The **SDK Contract IR**, not a physically shared generated type package, is the common source; this permits zero-Effect wire types and rich decoded Effect types to evolve independently. - Promise and Effect emitters each own their generated public type modules. The **SDK Contract IR**, not a physically shared generated type package, is the common source; this permits zero-Effect wire types and rich decoded Effect types to evolve independently.

View file

@ -2,7 +2,14 @@ export * as EventV2 from "./event"
import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect" import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Event } from "@opencode-ai/schema/event" import { Event } from "@opencode-ai/schema/event"
import type { Data, Definition, Payload } from "@opencode-ai/schema/event" import type {
Data,
Definition,
DurableDefinition,
Payload,
PublishedPayload,
UncommittedPayload,
} from "@opencode-ai/schema/event"
import { and, asc, eq, gt } from "drizzle-orm" import { and, asc, eq, gt } from "drizzle-orm"
import { Database } from "./database/database" import { Database } from "./database/database"
import { EventSequenceTable, EventTable } from "./event/sql" import { EventSequenceTable, EventTable } from "./event/sql"
@ -13,7 +20,14 @@ import { Durable } from "@opencode-ai/schema/durable-event-manifest"
export const ID = Event.ID export const ID = Event.ID
export type ID = import("@opencode-ai/schema/event").ID export type ID = import("@opencode-ai/schema/event").ID
export type { Data, Definition, Payload } from "@opencode-ai/schema/event" export type {
Data,
Definition,
DurableDefinition,
Payload,
PublishedPayload,
UncommittedPayload,
} from "@opencode-ai/schema/event"
export type Subscriber<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void> export type Subscriber<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
export type Unsubscribe = Effect.Effect<void> export type Unsubscribe = Effect.Effect<void>
@ -66,10 +80,13 @@ export interface Interface {
) => Effect.Effect<Payload<D>> ) => Effect.Effect<Payload<D>>
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>> readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
readonly all: () => Stream.Stream<Payload> readonly all: () => Stream.Stream<Payload>
readonly durable: (input: { readonly aggregateID: string; readonly after?: number }) => Stream.Stream<Payload> readonly durable: (input: {
readonly aggregateID: string
readonly after?: number
}) => Stream.Stream<Payload<DurableDefinition>>
/** @deprecated Use `all()` and consume the returned stream. */ /** @deprecated Use `all()` and consume the returned stream. */
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe> readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
readonly project: <D extends Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void> readonly project: <D extends DurableDefinition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
readonly replay: ( readonly replay: (
event: SerializedEvent, event: SerializedEvent,
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean }, options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
@ -124,8 +141,8 @@ export const layerWith = (options?: LayerOptions) =>
) )
function commitDurableEvent( function commitDurableEvent(
definition: Definition, definition: DurableDefinition,
event: Payload, event: UncommittedPayload<DurableDefinition>,
input?: { input?: {
readonly seq: number readonly seq: number
readonly aggregateID: string readonly aggregateID: string
@ -135,7 +152,7 @@ export const layerWith = (options?: LayerOptions) =>
commit?: (seq: number) => Effect.Effect<void>, commit?: (seq: number) => Effect.Effect<void>,
) { ) {
return Effect.gen(function* () { return Effect.gen(function* () {
const durable = definition?.durable const durable = definition.durable
if (durable) { if (durable) {
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate] const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
if (typeof aggregateID !== "string") { if (typeof aggregateID !== "string") {
@ -200,7 +217,7 @@ export const layerWith = (options?: LayerOptions) =>
.run() .run()
.pipe(Effect.orDie) .pipe(Effect.orDie)
} }
return return undefined
} }
yield* Effect.die( yield* Effect.die(
new InvalidDurableEventError({ new InvalidDurableEventError({
@ -210,7 +227,7 @@ export const layerWith = (options?: LayerOptions) =>
) )
} }
if (input && row?.ownerID && row.ownerID !== input.ownerID) { if (input && row?.ownerID && row.ownerID !== input.ownerID) {
return return undefined
} }
const seq = input?.seq ?? latest + 1 const seq = input?.seq ?? latest + 1
if (input && seq !== latest + 1) { if (input && seq !== latest + 1) {
@ -234,10 +251,10 @@ export const layerWith = (options?: LayerOptions) =>
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`, message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
}), }),
) )
const committed = { const committed: Payload<DurableDefinition> = {
...event, ...event,
durable: { aggregateID, seq, version: durable.version }, durable: { aggregateID, seq, version: durable.version },
} as Payload }
for (const projector of list) { for (const projector of list) {
yield* projector(committed) yield* projector(committed)
} }
@ -267,14 +284,14 @@ export const layerWith = (options?: LayerOptions) =>
]) ])
.run() .run()
.pipe(Effect.orDie) .pipe(Effect.orDie)
return { aggregateID, seq } return committed
}), }),
{ behavior: "immediate" }, { behavior: "immediate" },
) )
.pipe(Effect.orDie) .pipe(Effect.orDie)
if (committed) { if (committed) {
yield* Effect.forEach( yield* Effect.forEach(
pubsub.durable.get(committed.aggregateID) ?? [], pubsub.durable.get(committed.durable.aggregateID) ?? [],
(wake) => PubSub.publish(wake, undefined), (wake) => PubSub.publish(wake, undefined),
{ discard: true }, { discard: true },
) )
@ -287,7 +304,11 @@ export const layerWith = (options?: LayerOptions) =>
}) })
} }
function publishEvent<D extends Definition>(definition: D, event: Payload<D>, commit?: PublishOptions["commit"]) { function publishEvent<D extends Definition>(
definition: D,
event: UncommittedPayload<D>,
commit?: PublishOptions["commit"],
): Effect.Effect<PublishedPayload<D>> {
return Effect.gen(function* () { return Effect.gen(function* () {
if (!definition?.durable && commit) if (!definition?.durable && commit)
return yield* Effect.die( return yield* Effect.die(
@ -297,22 +318,22 @@ export const layerWith = (options?: LayerOptions) =>
}), }),
) )
if (definition?.durable) { if (definition?.durable) {
const committed = yield* commitDurableEvent(definition, event as Payload, undefined, commit) const committed = yield* commitDurableEvent(
if (committed) { definition,
event = { event as UncommittedPayload<DurableDefinition>,
...event, undefined,
durable: { commit,
aggregateID: committed.aggregateID, )
seq: committed.seq, if (!committed)
version: definition.durable.version, return yield* Effect.die(
}, new InvalidDurableEventError({ type: event.type, message: "New durable event was not committed" }),
} )
yield* notify(event as Payload, true) yield* notify(committed, true)
return event return committed as PublishedPayload<D>
}
} }
yield* notify(event as Payload, false) const published = event as PublishedPayload<D>
return event yield* notify(published, false)
return published
}) })
} }
@ -353,7 +374,7 @@ export const layerWith = (options?: LayerOptions) =>
type: definition.type, type: definition.type,
...(location ? { location } : {}), ...(location ? { location } : {}),
data, data,
} as Payload<D>, } as UncommittedPayload<D>,
options?.commit, options?.commit,
) )
}) })
@ -370,11 +391,11 @@ export const layerWith = (options?: LayerOptions) =>
new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }), new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }),
) )
} else { } else {
const payload = { const payload: UncommittedPayload<DurableDefinition> = {
id: event.id, id: event.id,
type: definition.type, type: definition.type,
data: Schema.decodeUnknownSync(definition.data)(event.data), data: Schema.decodeUnknownSync(definition.data)(event.data),
} as Payload }
const committed = yield* commitDurableEvent(definition, payload, { const committed = yield* commitDurableEvent(definition, payload, {
seq: event.seq, seq: event.seq,
aggregateID: event.aggregateID, aggregateID: event.aggregateID,
@ -382,17 +403,7 @@ export const layerWith = (options?: LayerOptions) =>
strictOwner: options?.strictOwner, strictOwner: options?.strictOwner,
}) })
if (committed && options?.publish) { if (committed && options?.publish) {
yield* notify( yield* notify(committed, true)
{
...payload,
durable: {
aggregateID: committed.aggregateID,
seq: committed.seq,
version: definition.durable.version,
},
},
true,
)
} }
} }
}) })
@ -459,7 +470,7 @@ export const layerWith = (options?: LayerOptions) =>
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all) const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all)
const decodeSerializedEvent = (event: SerializedEvent) => { const decodeSerializedEvent = (event: SerializedEvent): Payload<DurableDefinition> => {
const definition = Durable.get(event.type) const definition = Durable.get(event.type)
if (!definition?.durable) { if (!definition?.durable) {
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }) throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
@ -516,7 +527,10 @@ export const layerWith = (options?: LayerOptions) =>
return subscription return subscription
}) })
const durable = (input: { readonly aggregateID: string; readonly after?: number }): Stream.Stream<Payload> => const durable = (input: {
readonly aggregateID: string
readonly after?: number
}): Stream.Stream<Payload<DurableDefinition>> =>
Stream.unwrap( Stream.unwrap(
Effect.gen(function* () { Effect.gen(function* () {
const wakes = yield* subscribeDurable(input.aggregateID) const wakes = yield* subscribeDurable(input.aggregateID)
@ -524,7 +538,7 @@ export const layerWith = (options?: LayerOptions) =>
const read = Effect.suspend(() => readAfter(input.aggregateID, sequence)).pipe( const read = Effect.suspend(() => readAfter(input.aggregateID, sequence)).pipe(
Effect.tap((events) => Effect.tap((events) =>
Effect.sync(() => { Effect.sync(() => {
sequence = events.at(-1)?.durable?.seq ?? sequence sequence = events.at(-1)?.durable.seq ?? sequence
}), }),
), ),
) )
@ -546,7 +560,7 @@ export const layerWith = (options?: LayerOptions) =>
}) })
}) })
const project = <D extends Definition>(definition: D, projector: Subscriber<D>): Effect.Effect<void> => const project = <D extends DurableDefinition>(definition: D, projector: Subscriber<D>): Effect.Effect<void> =>
Effect.sync(() => { Effect.sync(() => {
const list = projectors.get(definition.type) ?? [] const list = projectors.get(definition.type) ?? []
list.push((event) => projector(event as Payload<D>)) list.push((event) => projector(event as Payload<D>))

View file

@ -116,7 +116,7 @@ describe("EventV2", () => {
const event = yield* events.publish(VersionedMessage, { id: "one", text: "hello" }) const event = yield* events.publish(VersionedMessage, { id: "one", text: "hello" })
expect(event.type).toBe("test.versioned") expect(event.type).toBe("test.versioned")
expect(event.durable?.version).toBe(2) expect(event.durable.version).toBe(2)
}), }),
) )
@ -764,7 +764,7 @@ describe("EventV2", () => {
const replayed = { const replayed = {
id: published.id, id: published.id,
type: EventV2.versionedType(DurableMessage.type, 1), type: EventV2.versionedType(DurableMessage.type, 1),
seq: published.durable!.seq, seq: published.durable.seq,
aggregateID, aggregateID,
data: published.data, data: published.data,
} }

View file

@ -99,6 +99,16 @@ describe("PublicApi OpenAPI v2 errors", () => {
}) })
}) })
test("documents durable metadata only on durable events", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
const durable = spec.components.schemas.V2EventSessionCreated
const live = spec.components.schemas.V2EventSessionNextTextDelta
expect(durable?.required).toContain("durable")
expect(durable?.properties?.durable).toBeDefined()
expect(live?.properties?.durable).toBeUndefined()
})
test("preserves /api auth responses", () => { test("preserves /api auth responses", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec

View file

@ -9,6 +9,12 @@ import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater" import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionMessage } from "@opencode-ai/core/session/message"
function durable(sessionID: SessionID, seq?: number): { aggregateID: SessionID; seq: number; version: 1 }
function durable(sessionID: SessionID, seq: number, version: 2): { aggregateID: SessionID; seq: number; version: 2 }
function durable(sessionID: SessionID, seq = 0, version: 1 | 2 = 1) {
return { aggregateID: sessionID, seq, version }
}
test.skip("step snapshots carry over to assistant messages", () => { test.skip("step snapshots carry over to assistant messages", () => {
const state: SessionMessageUpdater.MemoryState = { messages: [] } const state: SessionMessageUpdater.MemoryState = { messages: [] }
const sessionID = SessionID.make("session") const sessionID = SessionID.make("session")
@ -17,6 +23,7 @@ test.skip("step snapshots carry over to assistant messages", () => {
Effect.runSync( Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(), id: EventV2.ID.create(),
durable: durable(sessionID),
type: "session.next.step.started", type: "session.next.step.started",
data: { data: {
sessionID, sessionID,
@ -38,6 +45,7 @@ test.skip("step snapshots carry over to assistant messages", () => {
Effect.runSync( Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(), id: EventV2.ID.create(),
durable: durable(sessionID, 1, 2),
type: "session.next.step.ended", type: "session.next.step.ended",
data: { data: {
sessionID, sessionID,
@ -70,6 +78,7 @@ test.skip("text ended populates assistant text content", () => {
Effect.runSync( Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(), id: EventV2.ID.create(),
durable: durable(sessionID),
type: "session.next.step.started", type: "session.next.step.started",
data: { data: {
sessionID, sessionID,
@ -88,6 +97,7 @@ test.skip("text ended populates assistant text content", () => {
Effect.runSync( Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(), id: EventV2.ID.create(),
durable: durable(sessionID, 1),
type: "session.next.text.started", type: "session.next.text.started",
data: { data: {
sessionID, sessionID,
@ -101,6 +111,7 @@ test.skip("text ended populates assistant text content", () => {
Effect.runSync( Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(), id: EventV2.ID.create(),
durable: durable(sessionID, 2),
type: "session.next.text.ended", type: "session.next.text.ended",
data: { data: {
sessionID, sessionID,
@ -126,6 +137,7 @@ test.skip("tool completion stores completed timestamp", () => {
Effect.runSync( Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(), id: EventV2.ID.create(),
durable: durable(sessionID),
type: "session.next.step.started", type: "session.next.step.started",
data: { data: {
sessionID, sessionID,
@ -144,6 +156,7 @@ test.skip("tool completion stores completed timestamp", () => {
Effect.runSync( Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(), id: EventV2.ID.create(),
durable: durable(sessionID, 1),
type: "session.next.tool.input.started", type: "session.next.tool.input.started",
data: { data: {
sessionID, sessionID,
@ -158,6 +171,7 @@ test.skip("tool completion stores completed timestamp", () => {
Effect.runSync( Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(), id: EventV2.ID.create(),
durable: durable(sessionID, 2),
type: "session.next.tool.called", type: "session.next.tool.called",
data: { data: {
sessionID, sessionID,
@ -174,6 +188,7 @@ test.skip("tool completion stores completed timestamp", () => {
Effect.runSync( Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(), id: EventV2.ID.create(),
durable: durable(sessionID, 3),
type: "session.next.tool.success", type: "session.next.tool.success",
data: { data: {
sessionID, sessionID,
@ -204,6 +219,7 @@ test("compaction events reduce to compaction message only when completed", () =>
Effect.runSync( Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id, id,
durable: durable(sessionID),
type: "session.next.compaction.started", type: "session.next.compaction.started",
data: { data: {
sessionID, sessionID,
@ -245,6 +261,7 @@ test("compaction events reduce to compaction message only when completed", () =>
Effect.runSync( Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(), id: EventV2.ID.create(),
durable: durable(sessionID, 3),
type: "session.next.compaction.ended", type: "session.next.compaction.ended",
data: { data: {
sessionID, sessionID,

View file

@ -8,18 +8,24 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
const fields = { const fields = {
id: Event.ID, id: Event.ID,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Int, version: Schema.Int })),
location: Schema.optional(Location.Ref), location: Schema.optional(Location.Ref),
} }
const schema = (definitions: ReadonlyArray<Definition>) => const schema = (definitions: ReadonlyArray<Definition>) =>
Schema.Union([ Schema.Union([
...definitions.map((definition) => ...definitions.map((definition) =>
Schema.Struct({ definition.durable
...fields, ? Schema.Struct({
type: Schema.Literal(definition.type), ...fields,
data: definition.data, durable: Event.durableEnvelope(definition.durable.version),
}).annotate({ identifier: `V2Event.${definition.type}` }), type: Schema.Literal(definition.type),
data: definition.data,
}).annotate({ identifier: `V2Event.${definition.type}` })
: Schema.Struct({
...fields,
type: Schema.Literal(definition.type),
data: definition.data,
}).annotate({ identifier: `V2Event.${definition.type}` }),
), ),
...(definitions.some((definition) => definition.type === "server.connected") ...(definitions.some((definition) => definition.type === "server.connected")
? [] ? []

View file

@ -3,7 +3,7 @@ export * as Event from "./event"
import { Schema } from "effect" import { Schema } from "effect"
import { ascending } from "./identifier" import { ascending } from "./identifier"
import { Location } from "./location" import { Location } from "./location"
import { statics } from "./schema" import { NonNegativeInt, statics } from "./schema"
export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe( export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
Schema.brand("Event.ID"), Schema.brand("Event.ID"),
@ -11,53 +11,95 @@ export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
) )
export type ID = typeof ID.Type export type ID = typeof ID.Type
export type Definition< export type DurableOptions = {
readonly version: number
readonly aggregate: string
}
export type DurableEnvelope<Version extends number = number> = {
readonly aggregateID: string
readonly seq: number
readonly version: Version
}
export const durableEnvelope = <const Version extends number>(version: Version) =>
Schema.Struct({ aggregateID: Schema.String, seq: NonNegativeInt, version: Schema.Literal(version) })
const NoDurableEnvelope = Schema.optional(Schema.Never)
export type LiveDefinition<
Type extends string = string, Type extends string = string,
DataSchema extends Schema.Codec<unknown, unknown> = Schema.Codec<unknown, unknown>, DataSchema extends Schema.Codec<unknown, unknown> = Schema.Codec<unknown, unknown>,
> = Schema.Top & { > = Schema.Top & {
readonly type: Type readonly type: Type
readonly durable?: {
readonly version: number
readonly aggregate: string
}
readonly data: DataSchema readonly data: DataSchema
readonly durable?: never
} }
export type DurableDefinition<
Type extends string = string,
DataSchema extends Schema.Codec<unknown, unknown> = Schema.Codec<unknown, unknown>,
Durability extends DurableOptions = DurableOptions,
> = Schema.Top & {
readonly type: Type
readonly data: DataSchema
readonly durable: Durability
}
export type Definition = LiveDefinition | DurableDefinition
type Defined<
Type extends string,
DataSchema extends Schema.Codec<unknown, unknown>,
Durability extends DurableOptions | undefined,
> = Durability extends DurableOptions
? DurableDefinition<Type, DataSchema, Durability>
: LiveDefinition<Type, DataSchema>
export type Data<D extends Definition> = Schema.Schema.Type<D["data"]> export type Data<D extends Definition> = Schema.Schema.Type<D["data"]>
export type Payload<D extends Definition = Definition> = { export type UncommittedPayload<D extends Definition = Definition> = D extends Definition
readonly id: ID ? {
readonly type: D["type"] readonly id: ID
readonly data: Data<D> readonly type: D["type"]
readonly durable?: { readonly data: Data<D>
readonly aggregateID: string readonly location?: Location.Ref
readonly seq: number readonly metadata?: Record<string, unknown>
readonly version: number }
} : never
readonly location?: Location.Ref
readonly metadata?: Record<string, unknown> export type PublishedPayload<D extends Definition = Definition> = D extends Definition
} ? UncommittedPayload<D> &
(D extends { readonly durable: infer Durability extends DurableOptions }
? { readonly durable: DurableEnvelope<Durability["version"]> }
: { readonly durable?: never })
: never
export type Payload<D extends Definition = Definition> = PublishedPayload<D>
type EventSchema<
Type extends string,
Fields extends Readonly<Record<PropertyKey, Schema.Codec<unknown, unknown>>>,
Durability extends DurableOptions | undefined,
> = Schema.Schema<PublishedPayload<Defined<Type, Schema.Struct<Fields>, Durability>>> &
Defined<Type, Schema.Struct<Fields>, Durability>
export function define< export function define<
const Type extends string, const Type extends string,
Fields extends Readonly<Record<PropertyKey, Schema.Codec<unknown, unknown>>>, Fields extends Readonly<Record<PropertyKey, Schema.Codec<unknown, unknown>>>,
const Durability extends DurableOptions | undefined = undefined,
>(input: { >(input: {
readonly type: Type readonly type: Type
readonly durable?: { readonly durable?: Durability
readonly version: number
readonly aggregate: string
}
readonly schema: Fields readonly schema: Fields
}): Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> & Definition<Type, Schema.Struct<Fields>> { }): EventSchema<Type, Fields, Durability> {
const data = Schema.Struct(input.schema) const data = Schema.Struct(input.schema)
return Object.assign( return Object.assign(
Schema.Struct({ Schema.Struct({
id: ID, id: ID,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
type: Schema.Literal(input.type), type: Schema.Literal(input.type),
durable: Schema.optional( durable: input.durable === undefined ? NoDurableEnvelope : durableEnvelope(input.durable.version),
Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number }),
),
location: Schema.optional(Location.Ref), location: Schema.optional(Location.Ref),
data, data,
}).annotate({ identifier: input.type }), }).annotate({ identifier: input.type }),
@ -66,7 +108,7 @@ export function define<
...(input.durable === undefined ? {} : { durable: input.durable }), ...(input.durable === undefined ? {} : { durable: input.durable }),
data, data,
}, },
) as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> & Definition<Type, Schema.Struct<Fields>> ) as unknown as EventSchema<Type, Fields, Durability>
} }
export function inventory<const Definitions extends ReadonlyArray<Definition>>(...definitions: Definitions) { export function inventory<const Definitions extends ReadonlyArray<Definition>>(...definitions: Definitions) {
@ -103,7 +145,7 @@ export function durable(definitions: ReadonlyArray<Definition>) {
if (result.has(key)) throw new Error(`Duplicate durable event definition for ${key}`) if (result.has(key)) throw new Error(`Duplicate durable event definition for ${key}`)
result.set(key, definition) result.set(key, definition)
return result return result
}, new Map<string, Definition>()), }, new Map<string, DurableDefinition>()),
) )
} }

View file

@ -34,4 +34,99 @@ describe("public event schemas", () => {
expect(Event.durable([definition]).get("test.durable.1")).toBe(definition) expect(Event.durable([definition]).get("test.durable.1")).toBe(definition)
}) })
test("durable definitions require published commit metadata", () => {
const definition = Event.define({
type: "test.durable",
durable: { aggregate: "id", version: 1 },
schema: { id: Schema.String },
})
const payload: typeof definition.Type = {
id: Event.ID.create(),
type: definition.type,
durable: { aggregateID: "aggregate", seq: 0, version: 1 },
data: { id: "aggregate" },
}
expect(Schema.is(definition)(payload)).toBe(true)
expect(
Schema.is(definition)({
id: Event.ID.create(),
type: definition.type,
data: { id: "aggregate" },
}),
).toBe(false)
expect(Schema.is(definition)({ ...payload, durable: { ...payload.durable, seq: -1 } })).toBe(false)
expect(Schema.is(definition)({ ...payload, durable: { ...payload.durable, version: 2 } })).toBe(false)
// @ts-expect-error Published durable payloads require commit metadata.
const missing: typeof definition.Type = { id: Event.ID.create(), type: definition.type, data: { id: "aggregate" } }
void missing
})
test("live definitions reject durable commit metadata", () => {
const definition = Event.define({
type: "test.live",
schema: { value: Schema.String },
})
const payload: typeof definition.Type = {
id: Event.ID.create(),
type: definition.type,
data: { value: "value" },
}
expect(Schema.is(definition)(payload)).toBe(true)
expect(
Schema.is(definition)({
...payload,
durable: { aggregateID: "aggregate", seq: 0, version: 1 },
}),
).toBe(false)
const invalid: typeof definition.Type = {
...payload,
// @ts-expect-error Live payloads cannot carry durable commit metadata.
durable: { aggregateID: "aggregate", seq: 0, version: 1 },
}
void invalid
})
test("mixed definition payloads preserve durability correlation", () => {
const durable = Event.define({
type: "test.mixed.durable",
durable: { aggregate: "id", version: 2 },
schema: { id: Schema.String },
})
const live = Event.define({
type: "test.mixed.live",
schema: { value: Schema.String },
})
type Mixed = Event.Payload<typeof durable | typeof live>
const committed: Mixed = {
id: Event.ID.create(),
type: durable.type,
durable: { aggregateID: "aggregate", seq: 0, version: 2 },
data: { id: "aggregate" },
}
const ephemeral: Mixed = {
id: Event.ID.create(),
type: live.type,
data: { value: "value" },
}
void committed
void ephemeral
// @ts-expect-error Durable union members require commit metadata.
const uncommitted: Mixed = { id: Event.ID.create(), type: durable.type, data: { id: "aggregate" } }
const falselyCommitted: Mixed = {
id: Event.ID.create(),
type: live.type,
// @ts-expect-error Live union members cannot carry durable commit metadata.
durable: { aggregateID: "aggregate", seq: 0, version: 2 },
data: { value: "value" },
}
void uncommitted
void falselyCommitted
})
}) })

File diff suppressed because it is too large Load diff