event.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. export * as EventV2 from "./event"
  2. import { Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
  3. import { eq } from "drizzle-orm"
  4. import { Database } from "./database/database"
  5. import { EventSequenceTable, EventTable } from "./event/sql"
  6. import { Location } from "./location"
  7. import { withStatics } from "./schema"
  8. import { Identifier } from "./util/identifier"
  9. export const ID = Schema.String.pipe(
  10. Schema.brand("Event.ID"),
  11. withStatics((schema) => ({ create: () => schema.make("evt_" + Identifier.ascending()) })),
  12. )
  13. export type ID = typeof ID.Type
  14. export type Definition<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = {
  15. readonly type: Type
  16. readonly sync?: {
  17. readonly version: number
  18. readonly aggregate: string
  19. }
  20. readonly data: DataSchema
  21. }
  22. export type Data<D extends Definition> = Schema.Schema.Type<D["data"]>
  23. export type Payload<D extends Definition = Definition> = {
  24. readonly id: ID
  25. readonly type: D["type"]
  26. readonly data: Data<D>
  27. readonly version?: number
  28. readonly location?: Location.Ref
  29. readonly metadata?: Record<string, unknown>
  30. }
  31. export type Projector<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
  32. type AnyProjector = (event: Payload) => Effect.Effect<void>
  33. export type Listener = (event: Payload) => Effect.Effect<void>
  34. export type Sync = (event: Payload) => Effect.Effect<void>
  35. export type Unsubscribe = Effect.Effect<void>
  36. export type SerializedEvent = {
  37. readonly id: ID
  38. readonly type: string
  39. readonly seq: number
  40. readonly aggregateID: string
  41. readonly data: Record<string, unknown>
  42. }
  43. export class InvalidSyncEventError extends Schema.TaggedErrorClass<InvalidSyncEventError>()(
  44. "EventV2.InvalidSyncEvent",
  45. {
  46. type: Schema.String,
  47. message: Schema.String,
  48. },
  49. ) {}
  50. export function versionedType(type: string, version: number) {
  51. return `${type}.${version}`
  52. }
  53. export const registry = new Map<string, Definition>()
  54. const syncRegistry = new Map<string, Definition & { readonly sync: NonNullable<Definition["sync"]> }>()
  55. export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
  56. readonly type: Type
  57. readonly sync?: {
  58. readonly version: number
  59. readonly aggregate: string
  60. }
  61. readonly schema: Fields
  62. }): Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> & Definition<Type, Schema.Struct<Fields>> {
  63. const Data = Schema.Struct(input.schema)
  64. const Payload = Schema.Struct({
  65. id: ID,
  66. metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
  67. type: Schema.Literal(input.type),
  68. version: Schema.optional(Schema.Number),
  69. location: Schema.optional(Location.Ref),
  70. data: Data,
  71. }).annotate({ identifier: input.type })
  72. const definition = Object.assign(Payload, {
  73. type: input.type,
  74. ...(input.sync === undefined ? {} : { sync: input.sync }),
  75. data: Data,
  76. })
  77. const existing = registry.get(input.type)
  78. if (input.sync === undefined || existing?.sync === undefined || input.sync.version >= existing.sync.version) {
  79. registry.set(input.type, definition)
  80. }
  81. if (input.sync)
  82. syncRegistry.set(
  83. versionedType(input.type, input.sync.version),
  84. definition as Definition & { readonly sync: NonNullable<Definition["sync"]> },
  85. )
  86. return definition as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> &
  87. Definition<Type, Schema.Struct<Fields>>
  88. }
  89. export function definitions() {
  90. return registry.values().toArray()
  91. }
  92. export interface PublishOptions {
  93. readonly id?: ID
  94. readonly metadata?: Record<string, unknown>
  95. readonly location?: Location.Ref
  96. }
  97. export interface Interface {
  98. readonly publish: <D extends Definition>(
  99. definition: D,
  100. data: Data<D>,
  101. options?: PublishOptions,
  102. ) => Effect.Effect<Payload<D>>
  103. readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
  104. readonly all: () => Stream.Stream<Payload>
  105. readonly sync: (handler: Sync) => Effect.Effect<Unsubscribe>
  106. readonly listen: (listener: Listener) => Effect.Effect<Unsubscribe>
  107. readonly project: <D extends Definition>(definition: D, projector: Projector<D>) => Effect.Effect<void>
  108. readonly replay: (
  109. event: SerializedEvent,
  110. options?: { readonly publish?: boolean; readonly ownerID?: string },
  111. ) => Effect.Effect<void>
  112. readonly replayAll: (
  113. events: SerializedEvent[],
  114. options?: { readonly publish?: boolean; readonly ownerID?: string },
  115. ) => Effect.Effect<string | undefined>
  116. readonly remove: (aggregateID: string) => Effect.Effect<void>
  117. readonly claim: (aggregateID: string, ownerID: string) => Effect.Effect<void>
  118. }
  119. export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
  120. export const layer = Layer.effect(
  121. Service,
  122. Effect.gen(function* () {
  123. const all = yield* PubSub.unbounded<Payload>()
  124. const typed = new Map<string, PubSub.PubSub<Payload>>()
  125. const projectors = new Map<string, AnyProjector[]>()
  126. const listeners = new Array<Listener>()
  127. const syncHandlers = new Array<Sync>()
  128. const { db } = yield* Database.Service
  129. const getOrCreate = (definition: Definition) =>
  130. Effect.gen(function* () {
  131. const existing = typed.get(definition.type)
  132. if (existing) return existing
  133. const pubsub = yield* PubSub.unbounded<Payload>()
  134. typed.set(definition.type, pubsub)
  135. return pubsub
  136. })
  137. yield* Effect.addFinalizer(() =>
  138. Effect.gen(function* () {
  139. yield* PubSub.shutdown(all)
  140. yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true })
  141. }),
  142. )
  143. function commitSyncEvent(
  144. event: Payload,
  145. input?: { readonly seq: number; readonly aggregateID: string; readonly ownerID?: string },
  146. ) {
  147. return Effect.gen(function* () {
  148. const definition = registry.get(event.type)
  149. const sync = definition?.sync
  150. if (sync) {
  151. if (event.version !== sync.version) {
  152. yield* Effect.die(
  153. new InvalidSyncEventError({
  154. type: event.type,
  155. message: `Expected event version ${sync.version}, got ${event.version}`,
  156. }),
  157. )
  158. }
  159. const aggregateID = (event.data as Record<string, unknown>)[sync.aggregate]
  160. if (typeof aggregateID !== "string") {
  161. yield* Effect.die(
  162. new InvalidSyncEventError({
  163. type: event.type,
  164. message: `Expected string aggregate field ${sync.aggregate}`,
  165. }),
  166. )
  167. } else {
  168. const list = projectors.get(event.type) ?? []
  169. yield* db
  170. .transaction(
  171. () =>
  172. Effect.gen(function* () {
  173. const row = yield* db
  174. .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
  175. .from(EventSequenceTable)
  176. .where(eq(EventSequenceTable.aggregate_id, aggregateID))
  177. .get()
  178. .pipe(Effect.orDie)
  179. const latest = row?.seq ?? -1
  180. if (input && input.seq <= latest) return
  181. if (input && row?.ownerID && row.ownerID !== input.ownerID) return
  182. const seq = input?.seq ?? latest + 1
  183. if (input && seq !== latest + 1) {
  184. yield* Effect.die(
  185. new InvalidSyncEventError({
  186. type: event.type,
  187. message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
  188. }),
  189. )
  190. }
  191. for (const projector of list) {
  192. yield* projector(event as Payload)
  193. }
  194. yield* db
  195. .insert(EventSequenceTable)
  196. .values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
  197. .onConflictDoUpdate({
  198. target: EventSequenceTable.aggregate_id,
  199. set: { seq },
  200. })
  201. .run()
  202. .pipe(Effect.orDie)
  203. yield* db
  204. .insert(EventTable)
  205. .values([
  206. {
  207. id: event.id,
  208. aggregate_id: aggregateID,
  209. seq,
  210. type: versionedType(definition.type, sync.version),
  211. data: event.data as Record<string, unknown>,
  212. },
  213. ])
  214. .run()
  215. .pipe(Effect.orDie)
  216. }),
  217. { behavior: "immediate" },
  218. )
  219. .pipe(Effect.orDie)
  220. }
  221. }
  222. })
  223. }
  224. function publishEvent<D extends Definition>(event: Payload<D>) {
  225. return Effect.gen(function* () {
  226. for (const sync of syncHandlers) {
  227. yield* sync(event as Payload)
  228. }
  229. yield* commitSyncEvent(event as Payload)
  230. for (const listener of listeners) {
  231. yield* listener(event as Payload)
  232. }
  233. const pubsub = typed.get(event.type)
  234. if (pubsub) yield* PubSub.publish(pubsub, event as Payload)
  235. yield* PubSub.publish(all, event as Payload)
  236. return event
  237. })
  238. }
  239. function publish<D extends Definition>(definition: D, data: Data<D>, options?: PublishOptions) {
  240. return Effect.gen(function* () {
  241. const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
  242. const location =
  243. options?.location ??
  244. (serviceLocation
  245. ? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
  246. : undefined)
  247. return yield* publishEvent({
  248. id: options?.id ?? ID.create(),
  249. ...(options?.metadata ? { metadata: options.metadata } : {}),
  250. type: definition.type,
  251. ...(definition.sync === undefined ? {} : { version: definition.sync.version }),
  252. ...(location ? { location } : {}),
  253. data,
  254. } as Payload<D>)
  255. })
  256. }
  257. function replay(event: SerializedEvent, options?: { readonly publish?: boolean; readonly ownerID?: string }) {
  258. return Effect.gen(function* () {
  259. const definition = syncRegistry.get(event.type)
  260. if (!definition) {
  261. yield* Effect.die(
  262. new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` }),
  263. )
  264. } else {
  265. const payload = {
  266. id: event.id,
  267. type: definition.type,
  268. version: definition.sync.version,
  269. data: event.data,
  270. } as Payload
  271. yield* commitSyncEvent(payload, { seq: event.seq, aggregateID: event.aggregateID, ownerID: options?.ownerID })
  272. if (options?.publish) {
  273. for (const listener of listeners) {
  274. yield* listener(payload)
  275. }
  276. const pubsub = typed.get(payload.type)
  277. if (pubsub) yield* PubSub.publish(pubsub, payload)
  278. yield* PubSub.publish(all, payload)
  279. }
  280. }
  281. })
  282. }
  283. function replayAll(events: SerializedEvent[], options?: { readonly publish?: boolean; readonly ownerID?: string }) {
  284. return Effect.gen(function* () {
  285. const source = events[0]?.aggregateID
  286. if (!source) return undefined
  287. if (events.some((event) => event.aggregateID !== source)) {
  288. yield* Effect.die(
  289. new InvalidSyncEventError({
  290. type: events[0]?.type ?? "unknown",
  291. message: "Replay events must belong to the same aggregate",
  292. }),
  293. )
  294. }
  295. const start = events[0]?.seq ?? 0
  296. for (const [index, event] of events.entries()) {
  297. const seq = start + index
  298. if (event.seq !== seq) {
  299. yield* Effect.die(
  300. new InvalidSyncEventError({
  301. type: event.type,
  302. message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`,
  303. }),
  304. )
  305. }
  306. }
  307. for (const event of events) {
  308. yield* replay(event, options)
  309. }
  310. return source
  311. })
  312. }
  313. function remove(aggregateID: string) {
  314. return db
  315. .transaction(() =>
  316. Effect.gen(function* () {
  317. yield* db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run()
  318. yield* db.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run()
  319. }),
  320. )
  321. .pipe(Effect.orDie)
  322. }
  323. function claim(aggregateID: string, ownerID: string) {
  324. return db
  325. .update(EventSequenceTable)
  326. .set({ owner_id: ownerID })
  327. .where(eq(EventSequenceTable.aggregate_id, aggregateID))
  328. .run()
  329. .pipe(Effect.orDie)
  330. }
  331. const subscribe = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
  332. Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe(
  333. Stream.map((event) => event as Payload<D>),
  334. )
  335. const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(all)
  336. const listen = (listener: Listener): Effect.Effect<Unsubscribe> =>
  337. Effect.sync(() => {
  338. listeners.push(listener)
  339. return Effect.sync(() => {
  340. const index = listeners.indexOf(listener)
  341. if (index >= 0) listeners.splice(index, 1)
  342. })
  343. })
  344. const sync = (handler: Sync): Effect.Effect<Unsubscribe> =>
  345. Effect.sync(() => {
  346. syncHandlers.push(handler)
  347. return Effect.sync(() => {
  348. const index = syncHandlers.indexOf(handler)
  349. if (index >= 0) syncHandlers.splice(index, 1)
  350. })
  351. })
  352. const project = <D extends Definition>(definition: D, projector: Projector<D>): Effect.Effect<void> =>
  353. Effect.sync(() => {
  354. const list = projectors.get(definition.type) ?? []
  355. list.push((event) => projector(event as Payload<D>))
  356. projectors.set(definition.type, list)
  357. })
  358. return Service.of({ publish, subscribe, all: streamAll, sync, listen, project, replay, replayAll, remove, claim })
  359. }),
  360. )
  361. export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))