1import { makeExecutionMemo } from "@/Runtime/ExecutionMemo";
2import { describe, expect, it } from "alchemy-test";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
6import * as Fiber from "effect/Fiber";
7import * as Layer from "effect/Layer";
8import * as Result from "effect/Result";
9import * as Scope from "effect/Scope";
10import * as TestClock from "effect/testing/TestClock";
11
12interface Pool {
13 readonly id: number;
14}
15
16describe("makeExecutionMemo", { tags: ["unit", "local"] }, () => {
17 it.effect(
18 "builds at most once per execution scope and releases at scope close",
19 () =>
20 Effect.gen(function* () {
21 let builds = 0;
22 const released: number[] = [];
23 const accessor = yield* makeExecutionMemo(
24 Effect.acquireRelease(
25 Effect.sync((): Pool => ({ id: ++builds })),
26 (pool) =>
27 Effect.sync(() => {
28 released.push(pool.id);
29 }),
30 ),
31 );
32
33 // Two accesses inside the same execution scope share one build.
34 const scopeA = yield* Scope.make();
35 const a1 = yield* accessor.pipe(Scope.provide(scopeA));
36 const a2 = yield* accessor.pipe(Scope.provide(scopeA));
37 expect(builds).toBe(1);
38 expect(a2).toBe(a1);
39 expect(released).toEqual([]);
40
41 // A different execution scope builds its own instance.
42 const scopeB = yield* Scope.make();
43 const b1 = yield* accessor.pipe(Scope.provide(scopeB));
44 expect(builds).toBe(2);
45 expect(b1).not.toBe(a1);
46
47 // Release finalizers fire when — and only when — the owning
48 // execution scope closes.
49 yield* Scope.close(scopeA, Exit.void);
50 expect(released).toEqual([1]);
51 yield* Scope.close(scopeB, Exit.void);
52 expect(released).toEqual([1, 2]);
53
54 // A fresh access on a fresh scope rebuilds (nothing is cached
55 // across executions).
56 const scopeC = yield* Scope.make();
57 yield* accessor.pipe(Scope.provide(scopeC));
58 expect(builds).toBe(3);
59 yield* Scope.close(scopeC, Exit.void);
60 expect(released).toEqual([1, 2, 3]);
61 }),
62 );
63
64 it.effect("concurrent first accesses join a single build", () =>
65 Effect.gen(function* () {
66 let builds = 0;
67 const accessor = yield* makeExecutionMemo(
68 Effect.acquireRelease(
69 // Asynchronous acquire so the second fiber arrives while the
70 // first build is still in flight.
71 Effect.sleep("10 millis").pipe(
72 Effect.map((): Pool => ({ id: ++builds })),
73 ),
74 () => Effect.void,
75 ),
76 );
77
78 const scope = yield* Scope.make();
79 const fiber = yield* Effect.forkChild(
80 Effect.all([accessor, accessor], {
81 concurrency: 2,
82 }).pipe(Scope.provide(scope)),
83 );
84 // `it.effect` runs under a TestClock — advance it so the in-flight
85 // build's sleep elapses while both fibers are already waiting on it.
86 yield* TestClock.adjust("10 millis");
87 const [x, y] = yield* Fiber.join(fiber);
88 expect(builds).toBe(1);
89 expect(y).toBe(x);
90 yield* Scope.close(scope, Exit.void);
91 }),
92 );
93
94 it.effect("distinct call sites get distinct memo cells", () =>
95 Effect.gen(function* () {
96 let builds = 0;
97 const build = Effect.sync(() => ++builds);
98 const accessorA = yield* makeExecutionMemo(build);
99 const accessorB = yield* makeExecutionMemo(build);
100
101 const scope = yield* Scope.make();
102 const a = yield* accessorA.pipe(Scope.provide(scope));
103 const b = yield* accessorB.pipe(Scope.provide(scope));
104 expect(a).toBe(1);
105 expect(b).toBe(2);
106 // ...but each cell still memoizes within the scope.
107 expect(yield* accessorA.pipe(Scope.provide(scope))).toBe(1);
108 expect(yield* accessorB.pipe(Scope.provide(scope))).toBe(2);
109 yield* Scope.close(scope, Exit.void);
110 }),
111 );
112
113 it.effect(
114 "Layer.build inside the memo ties layer finalizers to the execution scope",
115 () =>
116 Effect.gen(function* () {
117 class Db extends Context.Service<Db, { readonly id: number }>()(
118 "ExecutionMemoTest/Db",
119 ) {}
120
121 let builds = 0;
122 let releases = 0;
123 const dbLayer = Layer.effect(
124 Db,
125 Effect.acquireRelease(
126 Effect.sync(() => ({ id: ++builds })),
127 () =>
128 Effect.sync(() => {
129 releases++;
130 }),
131 ),
132 );
133
134 // The Drizzle.Postgres shape: build a Layer against the ambient
135 // execution scope so its release fires when the event settles.
136 const accessor = yield* makeExecutionMemo(
137 Effect.gen(function* () {
138 const ctx = yield* Layer.build(dbLayer);
139 return Context.get(ctx, Db);
140 }),
141 );
142
143 const scope = yield* Scope.make();
144 const d1 = yield* accessor.pipe(Scope.provide(scope));
145 const d2 = yield* accessor.pipe(Scope.provide(scope));
146 expect(builds).toBe(1);
147 expect(d2).toBe(d1);
148 expect(releases).toBe(0);
149
150 yield* Scope.close(scope, Exit.void);
151 expect(releases).toBe(1);
152 }),
153 );
154
155 it.effect("build errors are surfaced and not poisoned across scopes", () =>
156 Effect.gen(function* () {
157 let attempts = 0;
158 const accessor = yield* makeExecutionMemo(
159 Effect.suspend(() => {
160 attempts++;
161 return attempts === 1
162 ? Effect.fail("boom" as const)
163 : Effect.succeed(attempts);
164 }),
165 );
166
167 const scopeA = yield* Scope.make();
168 const first = yield* accessor.pipe(Scope.provide(scopeA), Effect.result);
169 expect(Result.isFailure(first)).toBe(true);
170 yield* Scope.close(scopeA, Exit.void);
171
172 // A new execution scope re-runs the build rather than replaying the
173 // stale failure forever.
174 const scopeB = yield* Scope.make();
175 const second = yield* accessor.pipe(Scope.provide(scopeB));
176 expect(second).toBe(2);
177 yield* Scope.close(scopeB, Exit.void);
178 }),
179 );
180});