| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Scope from "effect/Scope"; |
| 3 | |
| 4 | /** |
| 5 | * Per-execution memos, keyed on the execution's `Scope` object. Every bridge |
| 6 | * (Worker event, Durable Object call, Workflow run, Lambda invoke) provides a |
| 7 | * fresh `Scope` per execution, so an entry lives exactly as long as its |
| 8 | * execution and is garbage-collected with the scope object. |
| 9 | */ |
| 10 | const caches = new WeakMap< |
| 11 | Scope.Scope, |
| 12 | Map<symbol, Effect.Effect<any, any, any>> |
| 13 | >(); |
| 14 | |
| 15 | /** |
| 16 | * Allocate a per-execution memo cell for `build`. The returned accessor runs |
| 17 | * `build` at most once per execution scope (Worker fetch/queue/scheduled |
| 18 | * event, DO call, Workflow run, Lambda invoke) and builds it WITH that scope |
| 19 | * (`Layer.build` / `Scope`-requiring acquires see the execution scope), so |
| 20 | * release finalizers fire when the event settles — never held across events. |
| 21 | * This is the only legal pooling shape on workerd (sockets are |
| 22 | * IoContext-pinned) and the correct one on Lambda (the instance scope must |
| 23 | * not own disposables). |
| 24 | * |
| 25 | * The `Effect.sync` wrapper allocates the memo's symbol key once per *call |
| 26 | * site* (i.e. once per layer construction), so distinct clients built from |
| 27 | * distinct `makeExecutionMemo` calls never share a memo cell even inside the |
| 28 | * same execution. |
| 29 | * |
| 30 | * The memo key and the finalizer target are always the same scope object |
| 31 | * (`yield* Effect.scope`), so they cannot disagree: wrapping queries in a |
| 32 | * nested `Effect.scoped` narrows both the memo and the resource's lifetime |
| 33 | * to that block. |
| 34 | * |
| 35 | * The accessor's `Scope` requirement is erased from its type — every runtime |
| 36 | * bridge provides a fresh execution `Scope`, exactly like |
| 37 | * `Drizzle.Postgres` before this primitive was extracted. Evaluating the |
| 38 | * accessor outside an execution scope is a defect. |
| 39 | */ |
| 40 | export const makeExecutionMemo = <A, E, R>( |
| 41 | build: Effect.Effect<A, E, R | Scope.Scope>, |
| 42 | ): Effect.Effect<Effect.Effect<A, E, Exclude<R, Scope.Scope>>> => |
| 43 | Effect.sync(function () { |
| 44 | const symbol = Symbol(); |
| 45 | |
| 46 | return Effect.gen(function* () { |
| 47 | const scope = yield* Effect.scope; |
| 48 | let cache = caches.get(scope); |
| 49 | if (cache === undefined) { |
| 50 | caches.set(scope, (cache = new Map())); |
| 51 | } |
| 52 | let memo = cache.get(symbol); |
| 53 | if (memo === undefined) { |
| 54 | // `Effect.cached` only allocates the memo cell — the build runs |
| 55 | // on first evaluation — so this yield is synchronous and the |
| 56 | // fiber cannot be interleaved between the miss check and the |
| 57 | // set: a concurrent first access joins this memo (evaluating the |
| 58 | // same cached effect) instead of building a second resource. |
| 59 | memo = yield* Effect.cached(build.pipe(Scope.provide(scope))); |
| 60 | cache.set(symbol, memo); |
| 61 | } |
| 62 | return yield* memo; |
| 63 | }) as Effect.Effect<A, E, Exclude<R, Scope.Scope>>; |
| 64 | }); |