bus.test.ts 45 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340
  1. import { describe, expect } from "bun:test"
  2. import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Ref, Schema, Stream } from "effect"
  3. import { Bus } from "@opencode-ai/core/bus"
  4. import { Event } from "@opencode-ai/schema/event"
  5. import { Session } from "@opencode-ai/schema/session"
  6. import { SessionEvent } from "@opencode-ai/schema/session-event"
  7. import { Database } from "@opencode-ai/core/database/database"
  8. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  9. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  10. import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
  11. import { Location } from "@opencode-ai/core/location"
  12. import { AbsolutePath } from "@opencode-ai/core/schema"
  13. import { Workspace } from "@opencode-ai/core/workspace"
  14. import { eq } from "drizzle-orm"
  15. import { location } from "./fixture/location"
  16. import { testEffect } from "./lib/effect"
  17. const locationLayer = Layer.succeed(
  18. Location.Service,
  19. Location.Service.of(
  20. location({ directory: AbsolutePath.make("project"), workspaceID: Workspace.ID.make("wrk_test") }),
  21. ),
  22. )
  23. const Message = Bus.ephemeral({
  24. type: "test.message",
  25. schema: {
  26. text: Schema.String,
  27. },
  28. })
  29. const SyncMessage = Bus.durable({
  30. type: "test.sync",
  31. durable: {
  32. version: 1,
  33. aggregate: "id",
  34. },
  35. schema: {
  36. id: Schema.String,
  37. text: Schema.String,
  38. },
  39. })
  40. const SyncSent = Bus.durable({
  41. type: "test.sent",
  42. durable: {
  43. version: 1,
  44. aggregate: "messageID",
  45. },
  46. schema: {
  47. messageID: Schema.String,
  48. text: Schema.String,
  49. },
  50. })
  51. const VersionedMessageV1 = Bus.durable({
  52. type: "test.versioned",
  53. durable: { version: 1, aggregate: "id" },
  54. schema: { id: Schema.String },
  55. })
  56. const VersionedMessageV2 = Bus.durable({
  57. type: "test.versioned",
  58. durable: { version: 2, aggregate: "id" },
  59. schema: { id: Schema.String },
  60. })
  61. const GlobalMessage = Bus.ephemeral({
  62. type: "test.global",
  63. schema: {
  64. text: Schema.String,
  65. },
  66. })
  67. const CountMessage = Bus.ephemeral({
  68. type: "test.count",
  69. schema: {
  70. count: Schema.Number,
  71. },
  72. })
  73. const VersionedMessage = Bus.durable({
  74. type: "test.versioned",
  75. durable: {
  76. version: 2,
  77. aggregate: "id",
  78. },
  79. schema: {
  80. id: Schema.String,
  81. text: Schema.String,
  82. },
  83. })
  84. const DurableMessage = SessionEvent.Renamed
  85. const durableData = (sessionID: Session.ID, text: string) => ({
  86. sessionID,
  87. title: text,
  88. })
  89. /** Followed log read without markers: the old `durable` stream shape. */
  90. const tail = (bus: Bus.Interface, input: { aggregateID: string; after?: number }) =>
  91. bus.log({ ...input, follow: true }).pipe(Stream.filter((item): item is Event.Payload => !Bus.isSynced(item)))
  92. const it = testEffect(
  93. AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, Location.node]), [
  94. [Location.node, locationLayer],
  95. [Bus.node, Bus.configured({ persist: true })],
  96. ]),
  97. )
  98. const itWithoutLocation = testEffect(
  99. AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [[Bus.node, Bus.configured({ persist: true })]]),
  100. )
  101. const itWithoutPersistence = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node])))
  102. describe("Bus", () => {
  103. it.effect("subscribes to multiple event definitions with a discriminated payload union", () =>
  104. Effect.gen(function* () {
  105. const bus = yield* Bus.Service
  106. // @ts-expect-error multi-definition subscriptions require at least one definition
  107. bus.subscribe([])
  108. const fiber = yield* bus
  109. .subscribe([Message, CountMessage])
  110. .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
  111. yield* Effect.yieldNow
  112. yield* bus.publish(Message, { text: "hello" })
  113. yield* bus.publish(CountMessage, { count: 2 })
  114. const received = Array.from(yield* Fiber.join(fiber)).map((event) =>
  115. event.type === "test.message" ? event.data.text : event.data.count,
  116. )
  117. expect(received).toEqual(["hello", 2])
  118. }),
  119. )
  120. it.effect("publishes events with the current location", () =>
  121. Effect.gen(function* () {
  122. const bus = yield* Bus.Service
  123. const fiber = yield* bus.subscribe(Message).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  124. yield* Effect.yieldNow
  125. const event = yield* bus.publish(Message, { text: "hello" })
  126. const received = Array.from(yield* Fiber.join(fiber))
  127. expect(received).toEqual([event])
  128. expect(event.type).toBe("test.message")
  129. expect(event).not.toHaveProperty("version")
  130. expect(event.data).toEqual({ text: "hello" })
  131. expect(event.location).toEqual({
  132. directory: AbsolutePath.make("project"),
  133. workspaceID: Workspace.ID.make("wrk_test"),
  134. })
  135. }),
  136. )
  137. itWithoutLocation.effect("omits location when no location is available", () =>
  138. Effect.gen(function* () {
  139. const bus = yield* Bus.Service
  140. const event = yield* bus.publish(GlobalMessage, { text: "hello" })
  141. expect(event).not.toHaveProperty("location")
  142. expect(event.type).toBe("test.global")
  143. }),
  144. )
  145. it.effect("publishes definition version", () =>
  146. Effect.gen(function* () {
  147. const bus = yield* Bus.Service
  148. const event = yield* bus.publish(VersionedMessage, { id: "one", text: "hello" })
  149. expect(event.type).toBe("test.versioned")
  150. expect(event.durable?.version).toBe(Event.Version.make(2))
  151. }),
  152. )
  153. it.effect("selects the latest durable definition independent of declaration order", () =>
  154. Effect.sync(() => {
  155. const latest = Bus.durable({
  156. type: "test.out-of-order",
  157. durable: { version: 2, aggregate: "id" },
  158. schema: { id: Schema.String },
  159. })
  160. const historical = Bus.durable({
  161. type: "test.out-of-order",
  162. durable: { version: 1, aggregate: "id" },
  163. schema: { id: Schema.String },
  164. })
  165. expect(Event.latest([latest, historical]).get("test.out-of-order")).toBe(latest)
  166. expect(Event.latest([historical, latest]).get("test.out-of-order")).toBe(latest)
  167. }),
  168. )
  169. it.effect("publishes to typed and wildcard subscriptions", () =>
  170. Effect.gen(function* () {
  171. const bus = yield* Bus.Service
  172. const typed = yield* bus.subscribe(Message).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  173. const wildcard = yield* bus.subscribe().pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  174. yield* Effect.yieldNow
  175. const event = yield* bus.publish(Message, { text: "hello" })
  176. expect(Array.from(yield* Fiber.join(typed))).toEqual([event])
  177. expect(Array.from(yield* Fiber.join(wildcard))).toEqual([event])
  178. }),
  179. )
  180. it.effect("runs projectors inline", () =>
  181. Effect.gen(function* () {
  182. const bus = yield* Bus.Service
  183. const received = new Array<Event.Payload>()
  184. yield* bus.project(SyncMessage, (event) =>
  185. Effect.sync(() => {
  186. received.push(event)
  187. }),
  188. )
  189. const event = yield* bus.publish(SyncMessage, { id: "one", text: "hello" })
  190. yield* bus.publish(SyncMessage, { id: "one", text: "after unsubscribe" })
  191. expect(received[0]).toEqual(event)
  192. expect(received[1]?.data).toEqual({ id: "one", text: "after unsubscribe" })
  193. }),
  194. )
  195. it.effect("commits local operational state inside a new durable event transaction", () =>
  196. Effect.gen(function* () {
  197. const bus = yield* Bus.Service
  198. const received = new Array<string>()
  199. const aggregateID = Event.ID.create()
  200. yield* bus.project(SyncMessage, () => Effect.sync(() => received.push("projector")))
  201. yield* bus.publish(
  202. SyncMessage,
  203. { id: aggregateID, text: "hello" },
  204. { commit: (seq) => Effect.sync(() => received.push(`commit:${seq}`)) },
  205. )
  206. expect(received).toEqual(["projector", "commit:0"])
  207. }),
  208. )
  209. it.effect("rolls back the durable event and projector when the local commit fails", () =>
  210. Effect.gen(function* () {
  211. const bus = yield* Bus.Service
  212. const { db } = yield* Database.Service
  213. const aggregateID = Event.ID.create()
  214. yield* db.run("CREATE TABLE IF NOT EXISTS event_commit_probe (value text NOT NULL)")
  215. yield* db.run("DELETE FROM event_commit_probe")
  216. yield* bus.project(SyncMessage, () =>
  217. db.run("INSERT INTO event_commit_probe (value) VALUES ('projected')").pipe(Effect.orDie, Effect.asVoid),
  218. )
  219. const exit = yield* bus
  220. .publish(SyncMessage, { id: aggregateID, text: "hello" }, { commit: () => Effect.die("commit failed") })
  221. .pipe(Effect.exit)
  222. expect(String(exit)).toContain("commit failed")
  223. expect(yield* db.all("SELECT value FROM event_commit_probe")).toEqual([])
  224. expect(yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all()).toEqual([])
  225. expect(
  226. yield* db.select().from(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).all(),
  227. ).toEqual([])
  228. }),
  229. )
  230. itWithoutPersistence.effect("projects durable events without retaining their payloads", () =>
  231. Effect.gen(function* () {
  232. const bus = yield* Bus.Service
  233. const { db } = yield* Database.Service
  234. const aggregateID = Event.ID.create()
  235. yield* db.run("CREATE TABLE IF NOT EXISTS event_commit_probe (value text NOT NULL)")
  236. yield* bus.project(SyncMessage, () =>
  237. db.run("INSERT INTO event_commit_probe (value) VALUES ('projected')").pipe(Effect.orDie, Effect.asVoid),
  238. )
  239. const event = yield* bus.publish(SyncMessage, { id: aggregateID, text: "hello" })
  240. expect(event.durable?.seq).toBe(Event.Seq.make(0))
  241. expect(yield* db.all("SELECT value FROM event_commit_probe")).toEqual([{ value: "projected" }])
  242. expect(yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all()).toEqual([])
  243. expect(
  244. yield* db.select().from(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).all(),
  245. ).toEqual([{ aggregate_id: aggregateID, seq: 0, owner_id: null }])
  246. }),
  247. )
  248. it.effect("rejects local commit hooks on live-only events", () =>
  249. Effect.gen(function* () {
  250. const bus = yield* Bus.Service
  251. const exit = yield* bus.publish(Message, { text: "hello" }, { commit: () => Effect.void }).pipe(Effect.exit)
  252. expect(String(exit)).toContain("Local commit hooks require a durable event")
  253. }),
  254. )
  255. it.effect("runs projectors before publishing to streams", () =>
  256. Effect.gen(function* () {
  257. const bus = yield* Bus.Service
  258. const received = new Array<string>()
  259. const fiber = yield* bus.subscribe().pipe(
  260. Stream.take(1),
  261. Stream.runForEach(() => Effect.sync(() => received.push("stream"))),
  262. Effect.forkScoped,
  263. )
  264. yield* bus.project(SyncMessage, (event) =>
  265. Effect.sync(() => {
  266. received.push(event.type)
  267. }),
  268. )
  269. yield* Effect.yieldNow
  270. yield* bus.publish(SyncMessage, { id: "one", text: "hello" })
  271. yield* Fiber.join(fiber)
  272. expect(received).toEqual([SyncMessage.type, "stream"])
  273. }),
  274. )
  275. it.effect("runs listeners inline after projectors", () =>
  276. Effect.gen(function* () {
  277. const bus = yield* Bus.Service
  278. const received = new Array<string>()
  279. yield* bus.project(SyncMessage, () =>
  280. Effect.sync(() => {
  281. received.push("projector")
  282. }),
  283. )
  284. const unsubscribe = yield* bus.listen(() =>
  285. Effect.sync(() => {
  286. received.push("listener")
  287. }),
  288. )
  289. yield* bus.publish(SyncMessage, { id: "one", text: "hello" })
  290. yield* unsubscribe
  291. yield* bus.publish(SyncMessage, { id: "one", text: "after unsubscribe" })
  292. expect(received).toEqual(["projector", "listener", "projector"])
  293. }),
  294. )
  295. it.effect("isolates observer defects after durable events commit", () =>
  296. Effect.gen(function* () {
  297. const bus = yield* Bus.Service
  298. const received = new Array<string>()
  299. yield* bus.listen(() => {
  300. throw new Error("listener defect")
  301. })
  302. yield* bus.listen((event) =>
  303. Effect.sync(() => {
  304. received.push(event.type)
  305. }),
  306. )
  307. const event = yield* bus.publish(SyncMessage, { id: "one", text: "hello" })
  308. expect(received).toEqual([SyncMessage.type])
  309. expect(event.durable?.seq).toBeNumber()
  310. }),
  311. )
  312. it.effect("notifies global listeners only after a durable event is committed", () =>
  313. Effect.gen(function* () {
  314. const bus = yield* Bus.Service
  315. const { db } = yield* Database.Service
  316. const aggregateID = Event.ID.create()
  317. const observed = new Array<{ id: string; seq: number }>()
  318. yield* bus.listen((event) =>
  319. event.type !== SyncMessage.type
  320. ? Effect.void
  321. : db
  322. .select({ id: EventTable.id, seq: EventTable.seq })
  323. .from(EventTable)
  324. .where(eq(EventTable.id, event.id))
  325. .get()
  326. .pipe(
  327. Effect.orDie,
  328. Effect.tap((row) =>
  329. Effect.sync(() => {
  330. if (row) observed.push(row)
  331. }),
  332. ),
  333. Effect.asVoid,
  334. ),
  335. )
  336. const event = yield* bus.publish(SyncMessage, { id: aggregateID, text: "committed" })
  337. if (!event.durable) throw new Error("Expected durable event metadata")
  338. expect(observed).toEqual([{ id: event.id, seq: event.durable.seq }])
  339. }),
  340. )
  341. it.effect("preserves observer interruption", () =>
  342. Effect.gen(function* () {
  343. const bus = yield* Bus.Service
  344. const { db } = yield* Database.Service
  345. yield* bus.listen(() => Effect.interrupt)
  346. const exit = yield* bus.publish(SyncMessage, { id: "interrupted", text: "hello" }).pipe(Effect.exit)
  347. const committed = yield* db
  348. .select({ id: EventTable.id })
  349. .from(EventTable)
  350. .where(eq(EventTable.aggregate_id, "interrupted"))
  351. .get()
  352. .pipe(Effect.orDie)
  353. expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBeTrue()
  354. expect(committed).toBeDefined()
  355. }),
  356. )
  357. it.effect("keeps live-only listener defects fail-fast", () =>
  358. Effect.gen(function* () {
  359. const bus = yield* Bus.Service
  360. const defect = new Error("listener defect")
  361. yield* bus.listen(() => Effect.die(defect))
  362. expect(yield* bus.publish(Message, { text: "hello" }).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
  363. }),
  364. )
  365. it.effect("inserts durable event rows on publish", () =>
  366. Effect.gen(function* () {
  367. const bus = yield* Bus.Service
  368. const { db } = yield* Database.Service
  369. const aggregateID = Event.ID.create()
  370. yield* bus.publish(SyncMessage, { id: aggregateID, text: "first" })
  371. const rows = yield* db
  372. .select()
  373. .from(EventTable)
  374. .where(eq(EventTable.aggregate_id, aggregateID))
  375. .all()
  376. .pipe(Effect.orDie)
  377. expect(rows).toHaveLength(1)
  378. expect(rows[0]?.type).toBe(Bus.versionedType(SyncMessage.type, 1))
  379. expect(rows[0]?.aggregate_id).toBe(aggregateID)
  380. }),
  381. )
  382. it.effect("increments durable event seq per aggregate", () =>
  383. Effect.gen(function* () {
  384. const bus = yield* Bus.Service
  385. const { db } = yield* Database.Service
  386. const aggregateID = Event.ID.create()
  387. yield* bus.publish(SyncMessage, { id: aggregateID, text: "first" })
  388. yield* bus.publish(SyncMessage, { id: aggregateID, text: "second" })
  389. const rows = yield* db
  390. .select()
  391. .from(EventTable)
  392. .where(eq(EventTable.aggregate_id, aggregateID))
  393. .all()
  394. .pipe(Effect.orDie)
  395. expect(rows.map((row) => row.seq)).toEqual([0, 1])
  396. }),
  397. )
  398. it.effect("replays durable aggregate events after a sequence and tails new events", () =>
  399. Effect.gen(function* () {
  400. const bus = yield* Bus.Service
  401. const aggregateID = Session.ID.create()
  402. yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
  403. yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
  404. const fiber = yield* tail(bus, { aggregateID, after: 0 }).pipe(
  405. Stream.take(2),
  406. Stream.runCollect,
  407. Effect.forkScoped,
  408. )
  409. yield* Effect.yieldNow
  410. yield* bus.publish(DurableMessage, durableData(aggregateID, "two"))
  411. expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
  412. [1, durableData(aggregateID, "one")],
  413. [2, durableData(aggregateID, "two")],
  414. ])
  415. }),
  416. )
  417. it.effect("catches durable aggregate events published during replay handoff", () =>
  418. Effect.gen(function* () {
  419. const bus = yield* Bus.Service
  420. const aggregateID = Session.ID.create()
  421. yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
  422. const fiber = yield* tail(bus, { aggregateID }).pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
  423. yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
  424. expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
  425. [0, durableData(aggregateID, "zero")],
  426. [1, durableData(aggregateID, "one")],
  427. ])
  428. }),
  429. )
  430. it.effect("retains a durable wake committed while historical replay is paused", () =>
  431. Effect.gen(function* () {
  432. const readStarted = yield* Deferred.make<void>()
  433. const continueRead = yield* Deferred.make<void>()
  434. let pause = true
  435. const eventLayer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
  436. [
  437. Bus.node,
  438. Bus.configured({
  439. persist: true,
  440. beforeAggregateRead: () =>
  441. pause
  442. ? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead)))
  443. : Effect.void,
  444. }),
  445. ],
  446. ])
  447. yield* Effect.gen(function* () {
  448. const bus = yield* Bus.Service
  449. const aggregateID = Session.ID.create()
  450. const fiber = yield* tail(bus, { aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  451. yield* Deferred.await(readStarted)
  452. pause = false
  453. yield* bus.publish(DurableMessage, durableData(aggregateID, "during handoff"))
  454. yield* Deferred.succeed(continueRead, undefined)
  455. expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
  456. [0, durableData(aggregateID, "during handoff")],
  457. ])
  458. }).pipe(Effect.provide(eventLayer))
  459. }),
  460. )
  461. it.effect("coalesces durable aggregate wakes while draining every committed event", () =>
  462. Effect.gen(function* () {
  463. const bus = yield* Bus.Service
  464. const aggregateID = Session.ID.create()
  465. const count = 64
  466. const fiber = yield* tail(bus, { aggregateID }).pipe(Stream.take(count), Stream.runCollect, Effect.forkScoped)
  467. yield* Effect.yieldNow
  468. for (let index = 0; index < count; index++) {
  469. yield* bus.publish(DurableMessage, durableData(aggregateID, String(index)))
  470. }
  471. expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual(
  472. Array.from({ length: count }, (_, index) => [index, durableData(aggregateID, String(index))]),
  473. )
  474. }),
  475. )
  476. it.effect("omits live-only events from durable aggregate streams", () =>
  477. Effect.gen(function* () {
  478. const bus = yield* Bus.Service
  479. const aggregateID = Session.ID.create()
  480. const fiber = yield* tail(bus, { aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  481. yield* Effect.yieldNow
  482. yield* bus.publish(Message, { text: "live only" })
  483. yield* bus.publish(DurableMessage, durableData(aggregateID, "durable"))
  484. expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([DurableMessage.type])
  485. }),
  486. )
  487. it.effect("uses custom sync aggregate field", () =>
  488. Effect.gen(function* () {
  489. const bus = yield* Bus.Service
  490. const { db } = yield* Database.Service
  491. const aggregateID = Event.ID.create()
  492. yield* bus.publish(SyncSent, { messageID: aggregateID, text: "sent" })
  493. const rows = yield* db
  494. .select()
  495. .from(EventTable)
  496. .where(eq(EventTable.aggregate_id, aggregateID))
  497. .all()
  498. .pipe(Effect.orDie)
  499. expect(rows).toHaveLength(1)
  500. expect(rows[0]?.aggregate_id).toBe(aggregateID)
  501. }),
  502. )
  503. it.effect("replays durable events through projectors", () =>
  504. Effect.gen(function* () {
  505. const bus = yield* Bus.Service
  506. const received = new Array<Event.Payload>()
  507. yield* bus.project(DurableMessage, (event) =>
  508. Effect.sync(() => {
  509. received.push(event)
  510. }),
  511. )
  512. const aggregateID = Session.ID.create()
  513. yield* bus.replay({
  514. id: Event.ID.create(),
  515. created: DateTime.makeUnsafe(0),
  516. type: Bus.versionedType(DurableMessage.type, 1),
  517. seq: 0,
  518. aggregateID,
  519. data: durableData(aggregateID, "hello"),
  520. })
  521. expect(received[0]?.type).toBe(DurableMessage.type)
  522. expect(received[0]?.data).toEqual(durableData(aggregateID, "hello"))
  523. }),
  524. )
  525. it.effect("replay inserts external event rows", () =>
  526. Effect.gen(function* () {
  527. const bus = yield* Bus.Service
  528. const { db } = yield* Database.Service
  529. const aggregateID = Session.ID.create()
  530. yield* bus.replay({
  531. id: Event.ID.create(),
  532. created: DateTime.makeUnsafe(0),
  533. type: Bus.versionedType(DurableMessage.type, 1),
  534. seq: 0,
  535. aggregateID,
  536. data: durableData(aggregateID, "replayed"),
  537. })
  538. const rows = yield* db
  539. .select()
  540. .from(EventTable)
  541. .where(eq(EventTable.aggregate_id, aggregateID))
  542. .all()
  543. .pipe(Effect.orDie)
  544. expect(rows).toHaveLength(1)
  545. expect(rows[0]?.aggregate_id).toBe(aggregateID)
  546. }),
  547. )
  548. it.effect(
  549. "replay rejects an envelope aggregate that differs from its payload without mutating the payload aggregate",
  550. () =>
  551. Effect.gen(function* () {
  552. const bus = yield* Bus.Service
  553. const { db } = yield* Database.Service
  554. const envelopeAggregateID = Session.ID.create()
  555. const payloadAggregateID = Session.ID.create()
  556. const received = new Array<Event.Payload>()
  557. yield* bus.publish(DurableMessage, durableData(payloadAggregateID, "seed"))
  558. yield* bus.project(DurableMessage, (event) =>
  559. Effect.sync(() => {
  560. received.push(event)
  561. }),
  562. )
  563. const exit = yield* bus
  564. .replay({
  565. id: Event.ID.create(),
  566. created: DateTime.makeUnsafe(0),
  567. type: Bus.versionedType(DurableMessage.type, 1),
  568. seq: 1,
  569. aggregateID: envelopeAggregateID,
  570. data: durableData(payloadAggregateID, "replayed"),
  571. })
  572. .pipe(Effect.exit)
  573. const rows = yield* db
  574. .select()
  575. .from(EventTable)
  576. .where(eq(EventTable.aggregate_id, payloadAggregateID))
  577. .all()
  578. .pipe(Effect.orDie)
  579. const sequence = yield* db
  580. .select({ seq: EventSequenceTable.seq })
  581. .from(EventSequenceTable)
  582. .where(eq(EventSequenceTable.aggregate_id, payloadAggregateID))
  583. .get()
  584. .pipe(Effect.orDie)
  585. expect(String(exit)).toContain("Aggregate mismatch")
  586. expect(received).toHaveLength(0)
  587. expect(rows).toHaveLength(1)
  588. expect(sequence).toEqual({ seq: 0 })
  589. }),
  590. )
  591. it.effect("replay defects on sequence mismatch", () =>
  592. Effect.gen(function* () {
  593. const bus = yield* Bus.Service
  594. const aggregateID = Session.ID.create()
  595. yield* bus.replay({
  596. id: Event.ID.create(),
  597. created: DateTime.makeUnsafe(0),
  598. type: Bus.versionedType(DurableMessage.type, 1),
  599. seq: 0,
  600. aggregateID,
  601. data: durableData(aggregateID, "first"),
  602. })
  603. const exit = yield* bus
  604. .replay({
  605. id: Event.ID.create(),
  606. created: DateTime.makeUnsafe(0),
  607. type: Bus.versionedType(DurableMessage.type, 1),
  608. seq: 5,
  609. aggregateID,
  610. data: durableData(aggregateID, "bad"),
  611. })
  612. .pipe(Effect.exit)
  613. expect(String(exit)).toContain("Sequence mismatch")
  614. }),
  615. )
  616. it.effect("replay decodes synchronized transformed values before projection", () =>
  617. Effect.gen(function* () {
  618. const bus = yield* Bus.Service
  619. const aggregateID = Session.ID.create()
  620. const received = new Array<typeof SessionEvent.InstructionsUpdated.Type>()
  621. yield* bus.project(SessionEvent.InstructionsUpdated, (event) =>
  622. Effect.sync(() => {
  623. received.push(event)
  624. }),
  625. )
  626. yield* bus.replay({
  627. id: Event.ID.create(),
  628. created: DateTime.makeUnsafe(0),
  629. type: Bus.versionedType(SessionEvent.InstructionsUpdated.type, 2),
  630. seq: 0,
  631. aggregateID,
  632. data: { sessionID: aggregateID, delta: { "core/context": "0".repeat(64) } },
  633. })
  634. expect(received[0]?.created).toEqual(DateTime.makeUnsafe(0))
  635. }),
  636. )
  637. it.effect("dispatches durable projectors by exact event version", () =>
  638. Effect.gen(function* () {
  639. const bus = yield* Bus.Service
  640. const aggregateID = Session.ID.create()
  641. const received = new Array<typeof VersionedMessageV2.Type>()
  642. yield* bus.project(VersionedMessageV2, (event) =>
  643. Effect.sync(() => {
  644. received.push(event)
  645. }),
  646. )
  647. yield* bus.publish(VersionedMessageV1, { id: aggregateID })
  648. yield* bus.publish(VersionedMessageV2, { id: aggregateID })
  649. expect(received).toHaveLength(1)
  650. expect(received[0]?.durable.version).toBe(Event.Version.make(2))
  651. }),
  652. )
  653. it.effect("replay defects on unknown event type", () =>
  654. Effect.gen(function* () {
  655. const bus = yield* Bus.Service
  656. const exit = yield* bus
  657. .replay({
  658. id: Event.ID.create(),
  659. created: DateTime.makeUnsafe(0),
  660. type: "unknown.event.1",
  661. seq: 0,
  662. aggregateID: Event.ID.create(),
  663. data: {},
  664. })
  665. .pipe(Effect.exit)
  666. expect(String(exit)).toContain("Unknown durable event type")
  667. }),
  668. )
  669. it.effect("replayAll validates contiguous aggregate events", () =>
  670. Effect.gen(function* () {
  671. const bus = yield* Bus.Service
  672. const aggregateID = Session.ID.create()
  673. const source = yield* bus.replayAll([
  674. {
  675. id: Event.ID.create(),
  676. created: DateTime.makeUnsafe(0),
  677. type: Bus.versionedType(DurableMessage.type, 1),
  678. seq: 0,
  679. aggregateID,
  680. data: durableData(aggregateID, "one"),
  681. },
  682. {
  683. id: Event.ID.create(),
  684. created: DateTime.makeUnsafe(0),
  685. type: Bus.versionedType(DurableMessage.type, 1),
  686. seq: 1,
  687. aggregateID,
  688. data: durableData(aggregateID, "two"),
  689. },
  690. ])
  691. expect(source).toBe(aggregateID)
  692. }),
  693. )
  694. it.effect("replayAll accepts later chunks after the first batch", () =>
  695. Effect.gen(function* () {
  696. const bus = yield* Bus.Service
  697. const { db } = yield* Database.Service
  698. const aggregateID = Session.ID.create()
  699. const one = yield* bus.replayAll([
  700. {
  701. id: Event.ID.create(),
  702. created: DateTime.makeUnsafe(0),
  703. type: Bus.versionedType(DurableMessage.type, 1),
  704. seq: 0,
  705. aggregateID,
  706. data: durableData(aggregateID, "one"),
  707. },
  708. {
  709. id: Event.ID.create(),
  710. created: DateTime.makeUnsafe(0),
  711. type: Bus.versionedType(DurableMessage.type, 1),
  712. seq: 1,
  713. aggregateID,
  714. data: durableData(aggregateID, "two"),
  715. },
  716. ])
  717. const two = yield* bus.replayAll([
  718. {
  719. id: Event.ID.create(),
  720. created: DateTime.makeUnsafe(0),
  721. type: Bus.versionedType(DurableMessage.type, 1),
  722. seq: 2,
  723. aggregateID,
  724. data: durableData(aggregateID, "three"),
  725. },
  726. {
  727. id: Event.ID.create(),
  728. created: DateTime.makeUnsafe(0),
  729. type: Bus.versionedType(DurableMessage.type, 1),
  730. seq: 3,
  731. aggregateID,
  732. data: durableData(aggregateID, "four"),
  733. },
  734. ])
  735. const rows = yield* db
  736. .select()
  737. .from(EventTable)
  738. .where(eq(EventTable.aggregate_id, aggregateID))
  739. .all()
  740. .pipe(Effect.orDie)
  741. expect(one).toBe(aggregateID)
  742. expect(two).toBe(aggregateID)
  743. expect(rows.map((row) => row.seq)).toEqual([0, 1, 2, 3])
  744. }),
  745. )
  746. it.effect("claim fences replay owners", () =>
  747. Effect.gen(function* () {
  748. const bus = yield* Bus.Service
  749. const received = new Array<Event.Payload>()
  750. const aggregateID = Session.ID.create()
  751. yield* bus.publish(DurableMessage, durableData(aggregateID, "seed"))
  752. yield* bus.claim(aggregateID, "owner-a")
  753. yield* bus.project(DurableMessage, (event) =>
  754. Effect.sync(() => {
  755. received.push(event)
  756. }),
  757. )
  758. yield* bus.replay(
  759. {
  760. id: Event.ID.create(),
  761. created: DateTime.makeUnsafe(0),
  762. type: Bus.versionedType(DurableMessage.type, 1),
  763. seq: 1,
  764. aggregateID,
  765. data: durableData(aggregateID, "ignored"),
  766. },
  767. { ownerID: "owner-b" },
  768. )
  769. expect(received).toHaveLength(0)
  770. }),
  771. )
  772. it.effect("strict owner fences exact replay", () =>
  773. Effect.gen(function* () {
  774. const bus = yield* Bus.Service
  775. const aggregateID = Session.ID.create()
  776. const id = Event.ID.create()
  777. const replayed = {
  778. id,
  779. created: DateTime.makeUnsafe(0),
  780. type: Bus.versionedType(DurableMessage.type, 1),
  781. seq: 0,
  782. aggregateID,
  783. data: durableData(aggregateID, "owned"),
  784. }
  785. yield* bus.replay(replayed, { ownerID: "owner-a" })
  786. const exit = yield* bus.replay(replayed, { ownerID: "owner-b", strictOwner: true }).pipe(Effect.exit)
  787. expect(String(exit)).toContain("Replay owner mismatch")
  788. }),
  789. )
  790. it.effect("exact replay claims an unowned aggregate", () =>
  791. Effect.gen(function* () {
  792. const bus = yield* Bus.Service
  793. const { db } = yield* Database.Service
  794. const aggregateID = Session.ID.create()
  795. const published = yield* bus.publish(DurableMessage, durableData(aggregateID, "owned"))
  796. const replayed = {
  797. id: published.id,
  798. created: published.created,
  799. type: Bus.versionedType(DurableMessage.type, 1),
  800. seq: published.durable!.seq,
  801. aggregateID,
  802. data: published.data,
  803. }
  804. yield* bus.replay(replayed, { ownerID: "owner-a", strictOwner: true })
  805. const row = yield* db
  806. .select({ ownerID: EventSequenceTable.owner_id })
  807. .from(EventSequenceTable)
  808. .where(eq(EventSequenceTable.aggregate_id, aggregateID))
  809. .get()
  810. .pipe(Effect.orDie)
  811. expect(row?.ownerID).toBe("owner-a")
  812. const exit = yield* bus
  813. .replay(
  814. { ...replayed, id: Event.ID.create(), seq: 1, data: durableData(aggregateID, "conflict") },
  815. { ownerID: "owner-b", strictOwner: true },
  816. )
  817. .pipe(Effect.exit)
  818. expect(String(exit)).toContain("Replay owner mismatch")
  819. }),
  820. )
  821. it.effect("replay with owner claims an unowned sequence", () =>
  822. Effect.gen(function* () {
  823. const bus = yield* Bus.Service
  824. const { db } = yield* Database.Service
  825. const aggregateID = Session.ID.create()
  826. yield* bus.replay(
  827. {
  828. id: Event.ID.create(),
  829. created: DateTime.makeUnsafe(0),
  830. type: Bus.versionedType(DurableMessage.type, 1),
  831. seq: 0,
  832. aggregateID,
  833. data: durableData(aggregateID, "owned"),
  834. },
  835. { ownerID: "owner-1" },
  836. )
  837. const row = yield* db
  838. .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
  839. .from(EventSequenceTable)
  840. .where(eq(EventSequenceTable.aggregate_id, aggregateID))
  841. .get()
  842. .pipe(Effect.orDie)
  843. expect(row).toEqual({ seq: 0, ownerID: "owner-1" })
  844. }),
  845. )
  846. it.effect("replay claims an existing unowned sequence before fencing a different owner", () =>
  847. Effect.gen(function* () {
  848. const bus = yield* Bus.Service
  849. const { db } = yield* Database.Service
  850. const aggregateID = Session.ID.create()
  851. yield* bus.publish(DurableMessage, durableData(aggregateID, "local"))
  852. yield* bus.replay(
  853. {
  854. id: Event.ID.create(),
  855. created: DateTime.makeUnsafe(0),
  856. type: Bus.versionedType(DurableMessage.type, 1),
  857. seq: 1,
  858. aggregateID,
  859. data: durableData(aggregateID, "claimed"),
  860. },
  861. { ownerID: "owner-1" },
  862. )
  863. yield* bus.replay(
  864. {
  865. id: Event.ID.create(),
  866. created: DateTime.makeUnsafe(0),
  867. type: Bus.versionedType(DurableMessage.type, 1),
  868. seq: 2,
  869. aggregateID,
  870. data: durableData(aggregateID, "fenced"),
  871. },
  872. { ownerID: "owner-2" },
  873. )
  874. const rows = yield* db
  875. .select()
  876. .from(EventTable)
  877. .where(eq(EventTable.aggregate_id, aggregateID))
  878. .all()
  879. .pipe(Effect.orDie)
  880. const sequence = yield* db
  881. .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
  882. .from(EventSequenceTable)
  883. .where(eq(EventSequenceTable.aggregate_id, aggregateID))
  884. .get()
  885. .pipe(Effect.orDie)
  886. expect(rows.map((row) => row.seq)).toEqual([0, 1])
  887. expect(sequence).toEqual({ seq: 1, ownerID: "owner-1" })
  888. }),
  889. )
  890. it.effect("strict replay rejects an owner conflict instead of silently skipping it", () =>
  891. Effect.gen(function* () {
  892. const bus = yield* Bus.Service
  893. const aggregateID = Session.ID.create()
  894. yield* bus.replay(
  895. {
  896. id: Event.ID.create(),
  897. created: DateTime.makeUnsafe(0),
  898. type: Bus.versionedType(DurableMessage.type, 1),
  899. seq: 0,
  900. aggregateID,
  901. data: durableData(aggregateID, "claimed"),
  902. },
  903. { ownerID: "owner-1" },
  904. )
  905. const exit = yield* bus
  906. .replay(
  907. {
  908. id: Event.ID.create(),
  909. created: DateTime.makeUnsafe(0),
  910. type: Bus.versionedType(DurableMessage.type, 1),
  911. seq: 1,
  912. aggregateID,
  913. data: durableData(aggregateID, "conflict"),
  914. },
  915. { ownerID: "owner-2", strictOwner: true },
  916. )
  917. .pipe(Effect.exit)
  918. expect(String(exit)).toContain("Replay owner mismatch")
  919. }),
  920. )
  921. it.effect("publishes accepted replay with its durable sequence and suppresses stale replay", () =>
  922. Effect.gen(function* () {
  923. const bus = yield* Bus.Service
  924. const received = new Array<Event.Payload>()
  925. const aggregateID = Session.ID.create()
  926. yield* bus.listen((event) => Effect.sync(() => received.push(event)))
  927. const replayed = {
  928. id: Event.ID.create(),
  929. created: DateTime.makeUnsafe(0),
  930. type: Bus.versionedType(DurableMessage.type, 1),
  931. seq: 0,
  932. aggregateID,
  933. data: durableData(aggregateID, "replayed"),
  934. }
  935. yield* bus.replay(replayed, { publish: true })
  936. yield* bus.replay(replayed, { publish: true })
  937. expect(received).toMatchObject([{ id: replayed.id, durable: { seq: 0, version: 1 }, data: replayed.data }])
  938. }),
  939. )
  940. it.effect("rejects divergent stale replay without publishing it", () =>
  941. Effect.gen(function* () {
  942. const bus = yield* Bus.Service
  943. const received = new Array<Event.Payload>()
  944. const aggregateID = Session.ID.create()
  945. const replayed = {
  946. id: Event.ID.create(),
  947. created: DateTime.makeUnsafe(0),
  948. type: Bus.versionedType(DurableMessage.type, 1),
  949. seq: 0,
  950. aggregateID,
  951. data: durableData(aggregateID, "original"),
  952. }
  953. yield* bus.listen((event) => Effect.sync(() => received.push(event)))
  954. yield* bus.replay(replayed, { publish: true })
  955. const exit = yield* bus
  956. .replay({ ...replayed, data: durableData(aggregateID, "divergent") }, { publish: true })
  957. .pipe(Effect.exit)
  958. expect(String(exit)).toContain("Replay diverged")
  959. expect(received).toHaveLength(1)
  960. }),
  961. )
  962. it.effect("rejects an event ID reused at another aggregate position", () =>
  963. Effect.gen(function* () {
  964. const bus = yield* Bus.Service
  965. const aggregateID = Session.ID.create()
  966. const id = Event.ID.create()
  967. yield* bus.replay({
  968. id,
  969. created: DateTime.makeUnsafe(0),
  970. type: Bus.versionedType(DurableMessage.type, 1),
  971. seq: 0,
  972. aggregateID,
  973. data: durableData(aggregateID, "first"),
  974. })
  975. const exit = yield* bus
  976. .replay({
  977. id,
  978. created: DateTime.makeUnsafe(0),
  979. type: Bus.versionedType(DurableMessage.type, 1),
  980. seq: 1,
  981. aggregateID,
  982. data: durableData(aggregateID, "second"),
  983. })
  984. .pipe(Effect.exit)
  985. expect(String(exit)).toContain(`Event ${id} already exists`)
  986. }),
  987. )
  988. it.effect("replay from a different owner leaves claimed sequence unchanged", () =>
  989. Effect.gen(function* () {
  990. const bus = yield* Bus.Service
  991. const { db } = yield* Database.Service
  992. const aggregateID = Session.ID.create()
  993. const received = new Array<Event.Payload>()
  994. yield* bus.listen((event) => Effect.sync(() => received.push(event)))
  995. yield* bus.replay(
  996. {
  997. id: Event.ID.create(),
  998. created: DateTime.makeUnsafe(0),
  999. type: Bus.versionedType(DurableMessage.type, 1),
  1000. seq: 0,
  1001. aggregateID,
  1002. data: durableData(aggregateID, "first"),
  1003. },
  1004. { ownerID: "owner-1" },
  1005. )
  1006. yield* bus.replay(
  1007. {
  1008. id: Event.ID.create(),
  1009. created: DateTime.makeUnsafe(0),
  1010. type: Bus.versionedType(DurableMessage.type, 1),
  1011. seq: 1,
  1012. aggregateID,
  1013. data: durableData(aggregateID, "ignored"),
  1014. },
  1015. { ownerID: "owner-2", publish: true },
  1016. )
  1017. const rows = yield* db
  1018. .select()
  1019. .from(EventTable)
  1020. .where(eq(EventTable.aggregate_id, aggregateID))
  1021. .all()
  1022. .pipe(Effect.orDie)
  1023. const sequence = yield* db
  1024. .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
  1025. .from(EventSequenceTable)
  1026. .where(eq(EventSequenceTable.aggregate_id, aggregateID))
  1027. .get()
  1028. .pipe(Effect.orDie)
  1029. expect(rows).toHaveLength(1)
  1030. expect(sequence).toEqual({ seq: 0, ownerID: "owner-1" })
  1031. expect(received).toHaveLength(0)
  1032. }),
  1033. )
  1034. it.effect("claim updates the event sequence owner", () =>
  1035. Effect.gen(function* () {
  1036. const bus = yield* Bus.Service
  1037. const { db } = yield* Database.Service
  1038. const aggregateID = Event.ID.create()
  1039. yield* bus.publish(SyncMessage, { id: aggregateID, text: "claimed" })
  1040. yield* bus.claim(aggregateID, "owner-1")
  1041. yield* bus.claim(aggregateID, "owner-2")
  1042. const row = yield* db
  1043. .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
  1044. .from(EventSequenceTable)
  1045. .where(eq(EventSequenceTable.aggregate_id, aggregateID))
  1046. .get()
  1047. .pipe(Effect.orDie)
  1048. expect(row).toEqual({ seq: 0, ownerID: "owner-2" })
  1049. }),
  1050. )
  1051. it.effect("remove clears durable event sequence", () =>
  1052. Effect.gen(function* () {
  1053. const bus = yield* Bus.Service
  1054. const received = new Array<Event.Payload>()
  1055. const aggregateID = Session.ID.create()
  1056. yield* bus.publish(DurableMessage, durableData(aggregateID, "seed"))
  1057. yield* bus.remove(aggregateID)
  1058. yield* bus.project(DurableMessage, (event) =>
  1059. Effect.sync(() => {
  1060. received.push(event)
  1061. }),
  1062. )
  1063. yield* bus.replay({
  1064. id: Event.ID.create(),
  1065. created: DateTime.makeUnsafe(0),
  1066. type: Bus.versionedType(DurableMessage.type, 1),
  1067. seq: 0,
  1068. aggregateID,
  1069. data: durableData(aggregateID, "replayed"),
  1070. })
  1071. expect(received[0]?.data).toEqual(durableData(aggregateID, "replayed"))
  1072. }),
  1073. )
  1074. it.effect("log without follow replays events and completes with a synced marker", () =>
  1075. Effect.gen(function* () {
  1076. const bus = yield* Bus.Service
  1077. const aggregateID = Session.ID.create()
  1078. yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
  1079. yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
  1080. const items = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
  1081. expect(items.map((item) => (Bus.isSynced(item) ? item.type : item.durable?.seq))).toEqual([
  1082. Event.Seq.make(0),
  1083. Event.Seq.make(1),
  1084. "log.synced",
  1085. ])
  1086. expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID, seq: Event.Seq.make(1) })
  1087. }),
  1088. )
  1089. it.effect("log synced marker omits seq for an empty log and keeps the cursor otherwise", () =>
  1090. Effect.gen(function* () {
  1091. const bus = yield* Bus.Service
  1092. const aggregateID = Session.ID.create()
  1093. const empty = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
  1094. yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
  1095. const drained = Array.from(yield* Stream.runCollect(bus.log({ aggregateID, after: 0 })))
  1096. expect(empty).toEqual([{ type: "log.synced", aggregateID }])
  1097. expect(empty[0]).not.toHaveProperty("seq")
  1098. expect(drained).toEqual([{ type: "log.synced", aggregateID, seq: Event.Seq.make(0) }])
  1099. }),
  1100. )
  1101. it.effect("log with follow emits the synced marker at the replay-to-live boundary", () =>
  1102. Effect.gen(function* () {
  1103. const bus = yield* Bus.Service
  1104. const aggregateID = Session.ID.create()
  1105. yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
  1106. const fiber = yield* bus
  1107. .log({ aggregateID, follow: true })
  1108. .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
  1109. yield* Effect.yieldNow
  1110. yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
  1111. const items = Array.from(yield* Fiber.join(fiber))
  1112. expect(items.map((item) => (Bus.isSynced(item) ? item : item.durable?.seq))).toEqual([
  1113. Event.Seq.make(0),
  1114. { type: "log.synced", aggregateID, seq: Event.Seq.make(0) },
  1115. Event.Seq.make(1),
  1116. ])
  1117. }),
  1118. )
  1119. it.effect("log replays across configured read pages", () =>
  1120. Effect.gen(function* () {
  1121. const eventLayer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
  1122. [Bus.node, Bus.configured({ persist: true, logReadPageSize: 2 })],
  1123. ])
  1124. yield* Effect.gen(function* () {
  1125. const bus = yield* Bus.Service
  1126. const aggregateID = Session.ID.create()
  1127. yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
  1128. yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
  1129. yield* bus.publish(DurableMessage, durableData(aggregateID, "two"))
  1130. yield* bus.publish(DurableMessage, durableData(aggregateID, "three"))
  1131. yield* bus.publish(DurableMessage, durableData(aggregateID, "four"))
  1132. const items = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
  1133. expect(items.map((item) => (Bus.isSynced(item) ? item.type : item.durable?.seq))).toEqual([
  1134. Event.Seq.make(0),
  1135. Event.Seq.make(1),
  1136. Event.Seq.make(2),
  1137. Event.Seq.make(3),
  1138. Event.Seq.make(4),
  1139. "log.synced",
  1140. ])
  1141. expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID, seq: Event.Seq.make(4) })
  1142. }).pipe(Effect.provide(eventLayer))
  1143. }),
  1144. )
  1145. it.effect("log with follow emits events committed during replay after the synced marker", () =>
  1146. Effect.gen(function* () {
  1147. const readStarted = yield* Deferred.make<void>()
  1148. const releaseRead = yield* Deferred.make<void>()
  1149. const firstRead = yield* Ref.make(true)
  1150. const eventLayer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
  1151. [
  1152. Bus.node,
  1153. Bus.configured({
  1154. persist: true,
  1155. beforeAggregateRead: () =>
  1156. Ref.getAndSet(firstRead, false).pipe(
  1157. Effect.flatMap((shouldBlock) => {
  1158. if (!shouldBlock) return Effect.void
  1159. return Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseRead)))
  1160. }),
  1161. ),
  1162. }),
  1163. ],
  1164. ])
  1165. yield* Effect.gen(function* () {
  1166. const bus = yield* Bus.Service
  1167. const aggregateID = Session.ID.create()
  1168. yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
  1169. const fiber = yield* bus
  1170. .log({ aggregateID, follow: true })
  1171. .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
  1172. yield* Deferred.await(readStarted)
  1173. yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
  1174. yield* Deferred.succeed(releaseRead, undefined)
  1175. const items = Array.from(yield* Fiber.join(fiber))
  1176. expect(items.map((item) => (Bus.isSynced(item) ? item : item.durable?.seq))).toEqual([
  1177. Event.Seq.make(0),
  1178. { type: "log.synced", aggregateID, seq: Event.Seq.make(0) },
  1179. Event.Seq.make(1),
  1180. ])
  1181. }).pipe(Effect.provide(eventLayer))
  1182. }),
  1183. )
  1184. })