bus.test.ts 47 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372
  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("publishes a durable batch atomically in provided order", () =>
  399. Effect.gen(function* () {
  400. const bus = yield* Bus.Service
  401. const { db } = yield* Database.Service
  402. const aggregateID = Event.ID.create()
  403. const observed = new Array<string>()
  404. yield* bus.project(SyncMessage, (event) =>
  405. Effect.sync(() => {
  406. observed.push(`project:${event.data.text}`)
  407. }),
  408. )
  409. yield* bus.listen((event) =>
  410. event.type === SyncMessage.type
  411. ? Effect.gen(function* () {
  412. const text = (event.data as { readonly text: string }).text
  413. const row = yield* db
  414. .select({ seq: EventSequenceTable.seq })
  415. .from(EventSequenceTable)
  416. .where(eq(EventSequenceTable.aggregate_id, aggregateID))
  417. .get()
  418. .pipe(Effect.orDie)
  419. observed.push(`notify:${text}:${row?.seq}`)
  420. })
  421. : Effect.void,
  422. )
  423. const events = yield* bus.publishAll([
  424. [SyncMessage, { id: aggregateID, text: "first" }],
  425. [SyncMessage, { id: aggregateID, text: "second" }],
  426. ])
  427. expect(events.map((event) => event.durable.seq)).toEqual([Event.Seq.make(0), Event.Seq.make(1)])
  428. expect(observed).toEqual(["project:first", "project:second", "notify:first:1", "notify:second:1"])
  429. expect(
  430. (yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all()).map(
  431. (row) => row.seq,
  432. ),
  433. ).toEqual([0, 1])
  434. }),
  435. )
  436. it.effect("rolls back every batch event when a projector fails", () =>
  437. Effect.gen(function* () {
  438. const bus = yield* Bus.Service
  439. const { db } = yield* Database.Service
  440. const aggregateID = Event.ID.create()
  441. const notifications = new Array<string>()
  442. yield* db.run("CREATE TABLE IF NOT EXISTS event_batch_probe (value text NOT NULL)")
  443. yield* db.run("DELETE FROM event_batch_probe")
  444. yield* bus.project(SyncMessage, (event) =>
  445. db
  446. .run(`INSERT INTO event_batch_probe (value) VALUES ('${event.data.text}')`)
  447. .pipe(
  448. Effect.orDie,
  449. Effect.andThen(event.data.text === "second" ? Effect.die("projector failed") : Effect.void),
  450. ),
  451. )
  452. yield* bus.listen((event) =>
  453. Effect.sync(() => {
  454. notifications.push(event.type)
  455. }),
  456. )
  457. const exit = yield* bus
  458. .publishAll([
  459. [SyncMessage, { id: aggregateID, text: "first" }],
  460. [SyncMessage, { id: aggregateID, text: "second" }],
  461. ])
  462. .pipe(Effect.exit)
  463. expect(String(exit)).toContain("projector failed")
  464. expect(yield* db.all("SELECT value FROM event_batch_probe")).toEqual([])
  465. expect(yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all()).toEqual([])
  466. expect(
  467. yield* db.select().from(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).all(),
  468. ).toEqual([])
  469. expect(notifications).toEqual([])
  470. }),
  471. )
  472. it.effect("does not interleave a concurrent publish with batch notifications", () =>
  473. Effect.gen(function* () {
  474. const bus = yield* Bus.Service
  475. const aggregateID = Event.ID.create()
  476. const firstObserved = yield* Deferred.make<void>()
  477. const continueNotifications = yield* Deferred.make<void>()
  478. const observed = new Array<string>()
  479. yield* bus.listen((event) => {
  480. if (event.type !== SyncMessage.type) return Effect.void
  481. const text = (event.data as { readonly text: string }).text
  482. return Effect.sync(() => observed.push(text)).pipe(
  483. Effect.andThen(text === "first" ? Deferred.succeed(firstObserved, undefined) : Effect.void),
  484. Effect.andThen(text === "first" ? Deferred.await(continueNotifications) : Effect.void),
  485. )
  486. })
  487. const batch = yield* bus
  488. .publishAll([
  489. [SyncMessage, { id: aggregateID, text: "first" }],
  490. [SyncMessage, { id: aggregateID, text: "second" }],
  491. ])
  492. .pipe(Effect.forkScoped)
  493. yield* Deferred.await(firstObserved)
  494. const single = yield* bus.publish(SyncMessage, { id: aggregateID, text: "third" }).pipe(Effect.forkScoped)
  495. yield* Effect.yieldNow
  496. expect(observed).toEqual(["first"])
  497. yield* Deferred.succeed(continueNotifications, undefined)
  498. yield* Fiber.join(batch)
  499. yield* Fiber.join(single)
  500. expect(observed).toEqual(["first", "second", "third"])
  501. }),
  502. )
  503. it.effect("replays durable aggregate events after a sequence and tails new events", () =>
  504. Effect.gen(function* () {
  505. const bus = yield* Bus.Service
  506. const aggregateID = Session.ID.create()
  507. yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
  508. yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
  509. const fiber = yield* tail(bus, { aggregateID, after: 0 }).pipe(
  510. Stream.take(2),
  511. Stream.runCollect,
  512. Effect.forkScoped,
  513. )
  514. yield* Effect.yieldNow
  515. yield* bus.publish(DurableMessage, durableData(aggregateID, "two"))
  516. expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
  517. [1, durableData(aggregateID, "one")],
  518. [2, durableData(aggregateID, "two")],
  519. ])
  520. }),
  521. )
  522. it.effect("catches durable aggregate events published during replay handoff", () =>
  523. Effect.gen(function* () {
  524. const bus = yield* Bus.Service
  525. const aggregateID = Session.ID.create()
  526. yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
  527. const fiber = yield* tail(bus, { aggregateID }).pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
  528. yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
  529. expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
  530. [0, durableData(aggregateID, "zero")],
  531. [1, durableData(aggregateID, "one")],
  532. ])
  533. }),
  534. )
  535. it.effect("retains a durable wake committed while historical replay is paused", () =>
  536. Effect.gen(function* () {
  537. const readStarted = yield* Deferred.make<void>()
  538. const continueRead = yield* Deferred.make<void>()
  539. let pause = true
  540. const eventLayer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
  541. [
  542. Bus.node,
  543. Bus.configured({
  544. persist: true,
  545. beforeAggregateRead: () =>
  546. pause
  547. ? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead)))
  548. : Effect.void,
  549. }),
  550. ],
  551. ])
  552. yield* Effect.gen(function* () {
  553. const bus = yield* Bus.Service
  554. const aggregateID = Session.ID.create()
  555. const fiber = yield* tail(bus, { aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  556. yield* Deferred.await(readStarted)
  557. pause = false
  558. yield* bus.publish(DurableMessage, durableData(aggregateID, "during handoff"))
  559. yield* Deferred.succeed(continueRead, undefined)
  560. expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
  561. [0, durableData(aggregateID, "during handoff")],
  562. ])
  563. }).pipe(Effect.provide(eventLayer))
  564. }),
  565. )
  566. it.effect("coalesces durable aggregate wakes while draining every committed event", () =>
  567. Effect.gen(function* () {
  568. const bus = yield* Bus.Service
  569. const aggregateID = Session.ID.create()
  570. const count = 64
  571. const fiber = yield* tail(bus, { aggregateID }).pipe(Stream.take(count), Stream.runCollect, Effect.forkScoped)
  572. yield* Effect.yieldNow
  573. for (let index = 0; index < count; index++) {
  574. yield* bus.publish(DurableMessage, durableData(aggregateID, String(index)))
  575. }
  576. expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual(
  577. Array.from({ length: count }, (_, index) => [index, durableData(aggregateID, String(index))]),
  578. )
  579. }),
  580. )
  581. it.effect("omits live-only events from durable aggregate streams", () =>
  582. Effect.gen(function* () {
  583. const bus = yield* Bus.Service
  584. const aggregateID = Session.ID.create()
  585. const fiber = yield* tail(bus, { aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  586. yield* Effect.yieldNow
  587. yield* bus.publish(Message, { text: "live only" })
  588. yield* bus.publish(DurableMessage, durableData(aggregateID, "durable"))
  589. expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([DurableMessage.type])
  590. }),
  591. )
  592. it.effect("uses custom sync aggregate field", () =>
  593. Effect.gen(function* () {
  594. const bus = yield* Bus.Service
  595. const { db } = yield* Database.Service
  596. const aggregateID = Event.ID.create()
  597. yield* bus.publish(SyncSent, { messageID: aggregateID, text: "sent" })
  598. const rows = yield* db
  599. .select()
  600. .from(EventTable)
  601. .where(eq(EventTable.aggregate_id, aggregateID))
  602. .all()
  603. .pipe(Effect.orDie)
  604. expect(rows).toHaveLength(1)
  605. expect(rows[0]?.aggregate_id).toBe(aggregateID)
  606. }),
  607. )
  608. it.effect("replays durable events through projectors", () =>
  609. Effect.gen(function* () {
  610. const bus = yield* Bus.Service
  611. const received = new Array<Event.Payload>()
  612. yield* bus.project(DurableMessage, (event) =>
  613. Effect.sync(() => {
  614. received.push(event)
  615. }),
  616. )
  617. const aggregateID = Session.ID.create()
  618. yield* bus.replay({
  619. id: Event.ID.create(),
  620. created: DateTime.makeUnsafe(0),
  621. type: Bus.versionedType(DurableMessage.type, 1),
  622. seq: 0,
  623. aggregateID,
  624. data: durableData(aggregateID, "hello"),
  625. })
  626. expect(received[0]?.type).toBe(DurableMessage.type)
  627. expect(received[0]?.data).toEqual(durableData(aggregateID, "hello"))
  628. }),
  629. )
  630. it.effect("replay inserts external event rows", () =>
  631. Effect.gen(function* () {
  632. const bus = yield* Bus.Service
  633. const { db } = yield* Database.Service
  634. const aggregateID = Session.ID.create()
  635. yield* bus.replay({
  636. id: Event.ID.create(),
  637. created: DateTime.makeUnsafe(0),
  638. type: Bus.versionedType(DurableMessage.type, 1),
  639. seq: 0,
  640. aggregateID,
  641. data: durableData(aggregateID, "replayed"),
  642. })
  643. const rows = yield* db
  644. .select()
  645. .from(EventTable)
  646. .where(eq(EventTable.aggregate_id, aggregateID))
  647. .all()
  648. .pipe(Effect.orDie)
  649. expect(rows).toHaveLength(1)
  650. expect(rows[0]?.aggregate_id).toBe(aggregateID)
  651. }),
  652. )
  653. it.effect(
  654. "replay rejects an envelope aggregate that differs from its payload without mutating the payload aggregate",
  655. () =>
  656. Effect.gen(function* () {
  657. const bus = yield* Bus.Service
  658. const { db } = yield* Database.Service
  659. const envelopeAggregateID = Session.ID.create()
  660. const payloadAggregateID = Session.ID.create()
  661. const received = new Array<Event.Payload>()
  662. yield* bus.publish(DurableMessage, durableData(payloadAggregateID, "seed"))
  663. yield* bus.project(DurableMessage, (event) =>
  664. Effect.sync(() => {
  665. received.push(event)
  666. }),
  667. )
  668. const exit = yield* bus
  669. .replay({
  670. id: Event.ID.create(),
  671. created: DateTime.makeUnsafe(0),
  672. type: Bus.versionedType(DurableMessage.type, 1),
  673. seq: 1,
  674. aggregateID: envelopeAggregateID,
  675. data: durableData(payloadAggregateID, "replayed"),
  676. })
  677. .pipe(Effect.exit)
  678. const rows = yield* db
  679. .select()
  680. .from(EventTable)
  681. .where(eq(EventTable.aggregate_id, payloadAggregateID))
  682. .all()
  683. .pipe(Effect.orDie)
  684. const sequence = yield* db
  685. .select({ seq: EventSequenceTable.seq })
  686. .from(EventSequenceTable)
  687. .where(eq(EventSequenceTable.aggregate_id, payloadAggregateID))
  688. .get()
  689. .pipe(Effect.orDie)
  690. expect(String(exit)).toContain("Aggregate mismatch")
  691. expect(received).toHaveLength(0)
  692. expect(rows).toHaveLength(1)
  693. expect(sequence).toEqual({ seq: 0 })
  694. }),
  695. )
  696. it.effect("replay defects on sequence mismatch", () =>
  697. Effect.gen(function* () {
  698. const bus = yield* Bus.Service
  699. const aggregateID = Session.ID.create()
  700. yield* bus.replay({
  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, "first"),
  707. })
  708. const exit = yield* bus
  709. .replay({
  710. id: Event.ID.create(),
  711. created: DateTime.makeUnsafe(0),
  712. type: Bus.versionedType(DurableMessage.type, 1),
  713. seq: 5,
  714. aggregateID,
  715. data: durableData(aggregateID, "bad"),
  716. })
  717. .pipe(Effect.exit)
  718. expect(String(exit)).toContain("Sequence mismatch")
  719. }),
  720. )
  721. it.effect("replay decodes synchronized transformed values before projection", () =>
  722. Effect.gen(function* () {
  723. const bus = yield* Bus.Service
  724. const aggregateID = Session.ID.create()
  725. const received = new Array<typeof SessionEvent.InstructionsUpdated.Type>()
  726. yield* bus.project(SessionEvent.InstructionsUpdated, (event) =>
  727. Effect.sync(() => {
  728. received.push(event)
  729. }),
  730. )
  731. yield* bus.replay({
  732. id: Event.ID.create(),
  733. created: DateTime.makeUnsafe(0),
  734. type: Bus.versionedType(SessionEvent.InstructionsUpdated.type, 2),
  735. seq: 0,
  736. aggregateID,
  737. data: { sessionID: aggregateID, delta: { "core/context": "0".repeat(64) } },
  738. })
  739. expect(received[0]?.created).toEqual(DateTime.makeUnsafe(0))
  740. }),
  741. )
  742. it.effect("dispatches durable projectors by exact event version", () =>
  743. Effect.gen(function* () {
  744. const bus = yield* Bus.Service
  745. const aggregateID = Session.ID.create()
  746. const received = new Array<typeof VersionedMessageV2.Type>()
  747. yield* bus.project(VersionedMessageV2, (event) =>
  748. Effect.sync(() => {
  749. received.push(event)
  750. }),
  751. )
  752. yield* bus.publish(VersionedMessageV1, { id: aggregateID })
  753. yield* bus.publish(VersionedMessageV2, { id: aggregateID })
  754. expect(received).toHaveLength(1)
  755. expect(received[0]?.durable.version).toBe(Event.Version.make(2))
  756. }),
  757. )
  758. it.effect("replay defects on unknown event type", () =>
  759. Effect.gen(function* () {
  760. const bus = yield* Bus.Service
  761. const exit = yield* bus
  762. .replay({
  763. id: Event.ID.create(),
  764. created: DateTime.makeUnsafe(0),
  765. type: "unknown.event.1",
  766. seq: 0,
  767. aggregateID: Event.ID.create(),
  768. data: {},
  769. })
  770. .pipe(Effect.exit)
  771. expect(String(exit)).toContain("Unknown durable event type")
  772. }),
  773. )
  774. it.effect("claim fences replay owners", () =>
  775. Effect.gen(function* () {
  776. const bus = yield* Bus.Service
  777. const received = new Array<Event.Payload>()
  778. const aggregateID = Session.ID.create()
  779. yield* bus.publish(DurableMessage, durableData(aggregateID, "seed"))
  780. yield* bus.claim(aggregateID, "owner-a")
  781. yield* bus.project(DurableMessage, (event) =>
  782. Effect.sync(() => {
  783. received.push(event)
  784. }),
  785. )
  786. yield* bus.replay(
  787. {
  788. id: Event.ID.create(),
  789. created: DateTime.makeUnsafe(0),
  790. type: Bus.versionedType(DurableMessage.type, 1),
  791. seq: 1,
  792. aggregateID,
  793. data: durableData(aggregateID, "ignored"),
  794. },
  795. { ownerID: "owner-b" },
  796. )
  797. expect(received).toHaveLength(0)
  798. }),
  799. )
  800. it.effect("strict owner fences exact replay", () =>
  801. Effect.gen(function* () {
  802. const bus = yield* Bus.Service
  803. const aggregateID = Session.ID.create()
  804. const id = Event.ID.create()
  805. const replayed = {
  806. id,
  807. created: DateTime.makeUnsafe(0),
  808. type: Bus.versionedType(DurableMessage.type, 1),
  809. seq: 0,
  810. aggregateID,
  811. data: durableData(aggregateID, "owned"),
  812. }
  813. yield* bus.replay(replayed, { ownerID: "owner-a" })
  814. const exit = yield* bus.replay(replayed, { ownerID: "owner-b", strictOwner: true }).pipe(Effect.exit)
  815. expect(String(exit)).toContain("Replay owner mismatch")
  816. }),
  817. )
  818. it.effect("exact replay claims an unowned aggregate", () =>
  819. Effect.gen(function* () {
  820. const bus = yield* Bus.Service
  821. const { db } = yield* Database.Service
  822. const aggregateID = Session.ID.create()
  823. const published = yield* bus.publish(DurableMessage, durableData(aggregateID, "owned"))
  824. const replayed = {
  825. id: published.id,
  826. created: published.created,
  827. type: Bus.versionedType(DurableMessage.type, 1),
  828. seq: published.durable!.seq,
  829. aggregateID,
  830. data: published.data,
  831. }
  832. yield* bus.replay(replayed, { ownerID: "owner-a", strictOwner: true })
  833. const row = yield* db
  834. .select({ ownerID: EventSequenceTable.owner_id })
  835. .from(EventSequenceTable)
  836. .where(eq(EventSequenceTable.aggregate_id, aggregateID))
  837. .get()
  838. .pipe(Effect.orDie)
  839. expect(row?.ownerID).toBe("owner-a")
  840. const exit = yield* bus
  841. .replay(
  842. { ...replayed, id: Event.ID.create(), seq: 1, data: durableData(aggregateID, "conflict") },
  843. { ownerID: "owner-b", strictOwner: true },
  844. )
  845. .pipe(Effect.exit)
  846. expect(String(exit)).toContain("Replay owner mismatch")
  847. }),
  848. )
  849. it.effect("replay with owner claims an unowned sequence", () =>
  850. Effect.gen(function* () {
  851. const bus = yield* Bus.Service
  852. const { db } = yield* Database.Service
  853. const aggregateID = Session.ID.create()
  854. yield* bus.replay(
  855. {
  856. id: Event.ID.create(),
  857. created: DateTime.makeUnsafe(0),
  858. type: Bus.versionedType(DurableMessage.type, 1),
  859. seq: 0,
  860. aggregateID,
  861. data: durableData(aggregateID, "owned"),
  862. },
  863. { ownerID: "owner-1" },
  864. )
  865. const row = yield* db
  866. .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
  867. .from(EventSequenceTable)
  868. .where(eq(EventSequenceTable.aggregate_id, aggregateID))
  869. .get()
  870. .pipe(Effect.orDie)
  871. expect(row).toEqual({ seq: 0, ownerID: "owner-1" })
  872. }),
  873. )
  874. it.effect("replay claims an existing unowned sequence before fencing a different owner", () =>
  875. Effect.gen(function* () {
  876. const bus = yield* Bus.Service
  877. const { db } = yield* Database.Service
  878. const aggregateID = Session.ID.create()
  879. yield* bus.publish(DurableMessage, durableData(aggregateID, "local"))
  880. yield* bus.replay(
  881. {
  882. id: Event.ID.create(),
  883. created: DateTime.makeUnsafe(0),
  884. type: Bus.versionedType(DurableMessage.type, 1),
  885. seq: 1,
  886. aggregateID,
  887. data: durableData(aggregateID, "claimed"),
  888. },
  889. { ownerID: "owner-1" },
  890. )
  891. yield* bus.replay(
  892. {
  893. id: Event.ID.create(),
  894. created: DateTime.makeUnsafe(0),
  895. type: Bus.versionedType(DurableMessage.type, 1),
  896. seq: 2,
  897. aggregateID,
  898. data: durableData(aggregateID, "fenced"),
  899. },
  900. { ownerID: "owner-2" },
  901. )
  902. const rows = yield* db
  903. .select()
  904. .from(EventTable)
  905. .where(eq(EventTable.aggregate_id, aggregateID))
  906. .all()
  907. .pipe(Effect.orDie)
  908. const sequence = yield* db
  909. .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
  910. .from(EventSequenceTable)
  911. .where(eq(EventSequenceTable.aggregate_id, aggregateID))
  912. .get()
  913. .pipe(Effect.orDie)
  914. expect(rows.map((row) => row.seq)).toEqual([0, 1])
  915. expect(sequence).toEqual({ seq: 1, ownerID: "owner-1" })
  916. }),
  917. )
  918. it.effect("strict replay rejects an owner conflict instead of silently skipping it", () =>
  919. Effect.gen(function* () {
  920. const bus = yield* Bus.Service
  921. const aggregateID = Session.ID.create()
  922. yield* bus.replay(
  923. {
  924. id: Event.ID.create(),
  925. created: DateTime.makeUnsafe(0),
  926. type: Bus.versionedType(DurableMessage.type, 1),
  927. seq: 0,
  928. aggregateID,
  929. data: durableData(aggregateID, "claimed"),
  930. },
  931. { ownerID: "owner-1" },
  932. )
  933. const exit = yield* bus
  934. .replay(
  935. {
  936. id: Event.ID.create(),
  937. created: DateTime.makeUnsafe(0),
  938. type: Bus.versionedType(DurableMessage.type, 1),
  939. seq: 1,
  940. aggregateID,
  941. data: durableData(aggregateID, "conflict"),
  942. },
  943. { ownerID: "owner-2", strictOwner: true },
  944. )
  945. .pipe(Effect.exit)
  946. expect(String(exit)).toContain("Replay owner mismatch")
  947. }),
  948. )
  949. it.effect("publishes accepted replay with its durable sequence and suppresses stale replay", () =>
  950. Effect.gen(function* () {
  951. const bus = yield* Bus.Service
  952. const received = new Array<Event.Payload>()
  953. const aggregateID = Session.ID.create()
  954. yield* bus.listen((event) => Effect.sync(() => received.push(event)))
  955. const replayed = {
  956. id: Event.ID.create(),
  957. created: DateTime.makeUnsafe(0),
  958. type: Bus.versionedType(DurableMessage.type, 1),
  959. seq: 0,
  960. aggregateID,
  961. data: durableData(aggregateID, "replayed"),
  962. }
  963. yield* bus.replay(replayed, { publish: true })
  964. yield* bus.replay(replayed, { publish: true })
  965. expect(received).toMatchObject([{ id: replayed.id, durable: { seq: 0, version: 1 }, data: replayed.data }])
  966. }),
  967. )
  968. it.effect("rejects divergent stale replay without publishing it", () =>
  969. Effect.gen(function* () {
  970. const bus = yield* Bus.Service
  971. const received = new Array<Event.Payload>()
  972. const aggregateID = Session.ID.create()
  973. const replayed = {
  974. id: Event.ID.create(),
  975. created: DateTime.makeUnsafe(0),
  976. type: Bus.versionedType(DurableMessage.type, 1),
  977. seq: 0,
  978. aggregateID,
  979. data: durableData(aggregateID, "original"),
  980. }
  981. yield* bus.listen((event) => Effect.sync(() => received.push(event)))
  982. yield* bus.replay(replayed, { publish: true })
  983. const exit = yield* bus
  984. .replay({ ...replayed, data: durableData(aggregateID, "divergent") }, { publish: true })
  985. .pipe(Effect.exit)
  986. expect(String(exit)).toContain("Replay diverged")
  987. expect(received).toHaveLength(1)
  988. }),
  989. )
  990. it.effect("rejects an event ID reused at another aggregate position", () =>
  991. Effect.gen(function* () {
  992. const bus = yield* Bus.Service
  993. const aggregateID = Session.ID.create()
  994. const id = Event.ID.create()
  995. yield* bus.replay({
  996. id,
  997. created: DateTime.makeUnsafe(0),
  998. type: Bus.versionedType(DurableMessage.type, 1),
  999. seq: 0,
  1000. aggregateID,
  1001. data: durableData(aggregateID, "first"),
  1002. })
  1003. const exit = yield* bus
  1004. .replay({
  1005. id,
  1006. created: DateTime.makeUnsafe(0),
  1007. type: Bus.versionedType(DurableMessage.type, 1),
  1008. seq: 1,
  1009. aggregateID,
  1010. data: durableData(aggregateID, "second"),
  1011. })
  1012. .pipe(Effect.exit)
  1013. expect(String(exit)).toContain(`Event ${id} already exists`)
  1014. }),
  1015. )
  1016. it.effect("replay from a different owner leaves claimed sequence unchanged", () =>
  1017. Effect.gen(function* () {
  1018. const bus = yield* Bus.Service
  1019. const { db } = yield* Database.Service
  1020. const aggregateID = Session.ID.create()
  1021. const received = new Array<Event.Payload>()
  1022. yield* bus.listen((event) => Effect.sync(() => received.push(event)))
  1023. yield* bus.replay(
  1024. {
  1025. id: Event.ID.create(),
  1026. created: DateTime.makeUnsafe(0),
  1027. type: Bus.versionedType(DurableMessage.type, 1),
  1028. seq: 0,
  1029. aggregateID,
  1030. data: durableData(aggregateID, "first"),
  1031. },
  1032. { ownerID: "owner-1" },
  1033. )
  1034. yield* bus.replay(
  1035. {
  1036. id: Event.ID.create(),
  1037. created: DateTime.makeUnsafe(0),
  1038. type: Bus.versionedType(DurableMessage.type, 1),
  1039. seq: 1,
  1040. aggregateID,
  1041. data: durableData(aggregateID, "ignored"),
  1042. },
  1043. { ownerID: "owner-2", publish: true },
  1044. )
  1045. const rows = yield* db
  1046. .select()
  1047. .from(EventTable)
  1048. .where(eq(EventTable.aggregate_id, aggregateID))
  1049. .all()
  1050. .pipe(Effect.orDie)
  1051. const sequence = yield* db
  1052. .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
  1053. .from(EventSequenceTable)
  1054. .where(eq(EventSequenceTable.aggregate_id, aggregateID))
  1055. .get()
  1056. .pipe(Effect.orDie)
  1057. expect(rows).toHaveLength(1)
  1058. expect(sequence).toEqual({ seq: 0, ownerID: "owner-1" })
  1059. expect(received).toHaveLength(0)
  1060. }),
  1061. )
  1062. it.effect("claim updates the event sequence owner", () =>
  1063. Effect.gen(function* () {
  1064. const bus = yield* Bus.Service
  1065. const { db } = yield* Database.Service
  1066. const aggregateID = Event.ID.create()
  1067. yield* bus.publish(SyncMessage, { id: aggregateID, text: "claimed" })
  1068. yield* bus.claim(aggregateID, "owner-1")
  1069. yield* bus.claim(aggregateID, "owner-2")
  1070. const row = yield* db
  1071. .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
  1072. .from(EventSequenceTable)
  1073. .where(eq(EventSequenceTable.aggregate_id, aggregateID))
  1074. .get()
  1075. .pipe(Effect.orDie)
  1076. expect(row).toEqual({ seq: 0, ownerID: "owner-2" })
  1077. }),
  1078. )
  1079. it.effect("remove clears durable event sequence", () =>
  1080. Effect.gen(function* () {
  1081. const bus = yield* Bus.Service
  1082. const received = new Array<Event.Payload>()
  1083. const aggregateID = Session.ID.create()
  1084. yield* bus.publish(DurableMessage, durableData(aggregateID, "seed"))
  1085. yield* bus.remove(aggregateID)
  1086. yield* bus.project(DurableMessage, (event) =>
  1087. Effect.sync(() => {
  1088. received.push(event)
  1089. }),
  1090. )
  1091. yield* bus.replay({
  1092. id: Event.ID.create(),
  1093. created: DateTime.makeUnsafe(0),
  1094. type: Bus.versionedType(DurableMessage.type, 1),
  1095. seq: 0,
  1096. aggregateID,
  1097. data: durableData(aggregateID, "replayed"),
  1098. })
  1099. expect(received[0]?.data).toEqual(durableData(aggregateID, "replayed"))
  1100. }),
  1101. )
  1102. it.effect("log without follow replays events and completes with a synced marker", () =>
  1103. Effect.gen(function* () {
  1104. const bus = yield* Bus.Service
  1105. const aggregateID = Session.ID.create()
  1106. yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
  1107. yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
  1108. const items = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
  1109. expect(items.map((item) => (Bus.isSynced(item) ? item.type : item.durable?.seq))).toEqual([
  1110. Event.Seq.make(0),
  1111. Event.Seq.make(1),
  1112. "log.synced",
  1113. ])
  1114. expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID, seq: Event.Seq.make(1) })
  1115. }),
  1116. )
  1117. it.effect("log synced marker omits seq for an empty log and keeps the cursor otherwise", () =>
  1118. Effect.gen(function* () {
  1119. const bus = yield* Bus.Service
  1120. const aggregateID = Session.ID.create()
  1121. const empty = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
  1122. yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
  1123. const drained = Array.from(yield* Stream.runCollect(bus.log({ aggregateID, after: 0 })))
  1124. expect(empty).toEqual([{ type: "log.synced", aggregateID }])
  1125. expect(empty[0]).not.toHaveProperty("seq")
  1126. expect(drained).toEqual([{ type: "log.synced", aggregateID, seq: Event.Seq.make(0) }])
  1127. }),
  1128. )
  1129. it.effect("log with follow emits the synced marker at the replay-to-live boundary", () =>
  1130. Effect.gen(function* () {
  1131. const bus = yield* Bus.Service
  1132. const aggregateID = Session.ID.create()
  1133. yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
  1134. const fiber = yield* bus
  1135. .log({ aggregateID, follow: true })
  1136. .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
  1137. yield* Effect.yieldNow
  1138. yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
  1139. const items = Array.from(yield* Fiber.join(fiber))
  1140. expect(items.map((item) => (Bus.isSynced(item) ? item : item.durable?.seq))).toEqual([
  1141. Event.Seq.make(0),
  1142. { type: "log.synced", aggregateID, seq: Event.Seq.make(0) },
  1143. Event.Seq.make(1),
  1144. ])
  1145. }),
  1146. )
  1147. it.effect("log replays across configured read pages", () =>
  1148. Effect.gen(function* () {
  1149. const eventLayer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
  1150. [Bus.node, Bus.configured({ persist: true, logReadPageSize: 2 })],
  1151. ])
  1152. yield* Effect.gen(function* () {
  1153. const bus = yield* Bus.Service
  1154. const aggregateID = Session.ID.create()
  1155. yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
  1156. yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
  1157. yield* bus.publish(DurableMessage, durableData(aggregateID, "two"))
  1158. yield* bus.publish(DurableMessage, durableData(aggregateID, "three"))
  1159. yield* bus.publish(DurableMessage, durableData(aggregateID, "four"))
  1160. const items = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
  1161. expect(items.map((item) => (Bus.isSynced(item) ? item.type : item.durable?.seq))).toEqual([
  1162. Event.Seq.make(0),
  1163. Event.Seq.make(1),
  1164. Event.Seq.make(2),
  1165. Event.Seq.make(3),
  1166. Event.Seq.make(4),
  1167. "log.synced",
  1168. ])
  1169. expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID, seq: Event.Seq.make(4) })
  1170. }).pipe(Effect.provide(eventLayer))
  1171. }),
  1172. )
  1173. it.effect("log with follow emits events committed during replay after the synced marker", () =>
  1174. Effect.gen(function* () {
  1175. const readStarted = yield* Deferred.make<void>()
  1176. const releaseRead = yield* Deferred.make<void>()
  1177. const firstRead = yield* Ref.make(true)
  1178. const eventLayer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
  1179. [
  1180. Bus.node,
  1181. Bus.configured({
  1182. persist: true,
  1183. beforeAggregateRead: () =>
  1184. Ref.getAndSet(firstRead, false).pipe(
  1185. Effect.flatMap((shouldBlock) => {
  1186. if (!shouldBlock) return Effect.void
  1187. return Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseRead)))
  1188. }),
  1189. ),
  1190. }),
  1191. ],
  1192. ])
  1193. yield* Effect.gen(function* () {
  1194. const bus = yield* Bus.Service
  1195. const aggregateID = Session.ID.create()
  1196. yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
  1197. const fiber = yield* bus
  1198. .log({ aggregateID, follow: true })
  1199. .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
  1200. yield* Deferred.await(readStarted)
  1201. yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
  1202. yield* Deferred.succeed(releaseRead, undefined)
  1203. const items = Array.from(yield* Fiber.join(fiber))
  1204. expect(items.map((item) => (Bus.isSynced(item) ? item : item.durable?.seq))).toEqual([
  1205. Event.Seq.make(0),
  1206. { type: "log.synced", aggregateID, seq: Event.Seq.make(0) },
  1207. Event.Seq.make(1),
  1208. ])
  1209. }).pipe(Effect.provide(eventLayer))
  1210. }),
  1211. )
  1212. })