@effect-app/infra 4.0.0-beta.200 → 4.0.0-beta.201

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,507 +1,249 @@
1
1
  /**
2
- * E2E tests for commands and queries using in-memory RPC transport.
2
+ * E2E tests for the invalidation key flow exercised end-to-end via the
3
+ * production wrap/unwrap path:
3
4
  *
4
- * These tests exercise the full server-side pipeline:
5
- * - `InvalidationMiddlewareLive` reads the `Invalidates` annotation, pre-populates the
6
- * `InvalidationSet`, and wraps command results in `{ payload, metadata }`.
7
- * - `RequestType` annotation decides whether to wrap (command) or not (query).
8
- * - `InvalidationSet.use()` dynamic key accumulation inside a handler.
5
+ * server: routing.ts wraps command success with `CommandResponseWithMetaData`
6
+ * and handler-thrown failure with `CommandFailureWithMetaData`;
7
+ * routing wraps stream values into `{_tag:"value"|"metadata"|"done"}`
8
+ * chunks. `InvalidationSet.use(...)` inside a handler accumulates keys.
9
+ * client: apiClientFactory unwraps both envelopes and forwards keys to
10
+ * `InvalidationKeysFromServer`.
9
11
  *
10
- * Transport is in-memory via `RpcTest.makeClient`, so no HTTP server is needed.
12
+ * Transport is real HTTP (NodeHttpServer on a loopback port) so the wire
13
+ * encoding is exercised too.
11
14
  */
15
+ import { NodeHttpServer } from "@effect/platform-node"
12
16
  import { expect, it } from "@effect/vitest"
13
- import { Effect, Layer, Ref, Stream } from "effect"
17
+ import { Effect, Exit, Layer, Option, Ref, Stream } from "effect"
14
18
  import { S } from "effect-app"
15
- import { InvalidationKeysFromServer, makeInvalidationKeysService } from "effect-app/client"
16
- import { InvalidationMiddleware } from "effect-app/middleware"
17
- import { Invalidation } from "effect-app/rpc"
18
- import { Rpc, RpcGroup, RpcTest } from "effect/unstable/rpc"
19
- import { InvalidationMiddlewareLive, RequestType } from "../src/api/routing/middleware.js"
19
+ import { ApiClientFactory, InvalidationKeysFromServer, makeInvalidationKeysService, makeRpcClient } from "effect-app/client"
20
+ import { HttpRouter, HttpServer } from "effect-app/http"
21
+ import { DefaultGenericMiddlewares } from "effect-app/middleware"
22
+ import { Invalidation, MiddlewareMaker } from "effect-app/rpc"
23
+ import { TaggedErrorClass } from "effect-app/Schema"
24
+ import { FetchHttpClient } from "effect/unstable/http"
25
+ import { RpcSerialization } from "effect/unstable/rpc"
26
+ import { createServer } from "http"
27
+ import { makeRouter } from "../src/api/routing.js"
28
+ import { DefaultGenericMiddlewaresLive } from "../src/api/routing/middleware.js"
29
+ import { AllowAnonymous, AllowAnonymousLive, RequestContextMap, RequireRoles, RequireRolesLive, SomeElseMiddleware, SomeElseMiddlewareLive, SomeService, Test, TestLive } from "./fixtures.js"
20
30
 
21
31
  // ---------------------------------------------------------------------------
22
- // Shared test keys
32
+ // Middleware (mirrors AppMiddleware shape — same composite as other e2e tests).
23
33
  // ---------------------------------------------------------------------------
24
34
 
25
- const StaticKey: Invalidation.InvalidationKey = ["static", "key"]
26
- const DynamicKey: Invalidation.InvalidationKey = ["dynamic", "key"]
27
-
28
- // ---------------------------------------------------------------------------
29
- // RPC group definition
30
- //
31
- // The success schema for commands is defined as the PLAIN type. At runtime,
32
- // `InvalidationMiddlewareLive` wraps command results into
33
- // `{ payload: <plain>, metadata: { invalidateQueries: [...] } }`.
34
- // `RpcTest.makeClient` uses no-serialization transport, so the wrapped runtime
35
- // value is what the test receives — no codec is applied to coerce it back.
36
- // ---------------------------------------------------------------------------
37
-
38
- const E2eRpcs = RpcGroup.make(
39
- // Plain query — result is not wrapped
40
- Rpc
41
- .make("getGreeting", {
42
- payload: S.Struct({ name: S.String }),
43
- success: S.String
44
- })
45
- .annotate(RequestType, "query")
46
- .middleware(InvalidationMiddleware),
47
- // Command — no invalidation keys
48
- Rpc
49
- .make("doNothing", { success: S.Void })
50
- .annotate(RequestType, "command")
51
- .middleware(InvalidationMiddleware),
52
- // Command — static `Invalidates` annotation
53
- Rpc
54
- .make("doWithStaticKey", {
55
- success: S.Struct({ count: S.Number })
56
- })
57
- .annotate(RequestType, "command")
58
- .annotate(Invalidation.Invalidates, [StaticKey])
59
- .middleware(InvalidationMiddleware),
60
- // Command — dynamic key added via `InvalidationSet.use`
61
- Rpc
62
- .make("doWithDynamicKey", { success: S.String })
63
- .annotate(RequestType, "command")
64
- .middleware(InvalidationMiddleware),
65
- // Command — static annotation + dynamic key combined
66
- Rpc
67
- .make("doWithBothKeys", { success: S.Number })
68
- .annotate(RequestType, "command")
69
- .annotate(Invalidation.Invalidates, [StaticKey])
70
- .middleware(InvalidationMiddleware),
71
- // Command — fails, V2: failure should include accumulated keys
72
- Rpc
73
- .make("doAndFail", {
74
- success: S.Void,
75
- error: S.Struct({ message: S.String })
76
- })
77
- .annotate(RequestType, "command")
78
- .annotate(Invalidation.Invalidates, [StaticKey])
79
- .middleware(InvalidationMiddleware),
80
- // Stream — no input
81
- Rpc
82
- .make("streamTicks", {
83
- success: S.Number,
84
- stream: true
85
- })
86
- .annotate(RequestType, "query")
87
- .middleware(InvalidationMiddleware),
88
- // Stream — with input payload
89
- Rpc
90
- .make("streamCountTo", {
91
- payload: S.Struct({ to: S.Number }),
92
- success: S.Number,
93
- stream: true
94
- })
95
- .annotate(RequestType, "query")
96
- .middleware(InvalidationMiddleware)
97
- )
98
-
99
- // ---------------------------------------------------------------------------
100
- // Server implementation layer
101
- //
102
- // Handlers return the PLAIN success value. `InvalidationMiddlewareLive`
103
- // intercepts every command call and wraps the result into
104
- // `{ payload, metadata: { invalidateQueries } }` before it reaches the client.
105
- // ---------------------------------------------------------------------------
106
-
107
- const E2eImplLayer = E2eRpcs.toLayer({
108
- getGreeting: ({ name }) => Effect.succeed(`Hello, ${name}!`),
109
- doNothing: () => Effect.void,
110
- doWithStaticKey: () => Effect.succeed({ count: 42 }),
111
- doWithDynamicKey: Effect.fnUntraced(function*() {
112
- yield* Invalidation.InvalidationSet.use((_) => _.add(DynamicKey))
113
- return "done"
114
- }),
115
- doWithBothKeys: Effect.fnUntraced(function*() {
116
- yield* Invalidation.InvalidationSet.use((_) => _.add(DynamicKey))
117
- return 99
118
- }),
119
- // V2: command that fails — middleware wraps the failure with accumulated keys
120
- doAndFail: Effect.fnUntraced(function*() {
121
- yield* Invalidation.InvalidationSet.use((_) => _.add(DynamicKey))
122
- return yield* Effect.fail({ message: "intentional failure" })
123
- }),
124
- streamTicks: () => Stream.fromIterable([1, 2, 3]),
125
- streamCountTo: ({ to }) => Stream.range(1, to)
126
- })
127
-
128
- const E2eTestLayer = Layer.merge(E2eImplLayer, InvalidationMiddlewareLive)
129
-
130
- // Helper: validates that the runtime-wrapped command result has the expected shape.
131
- // `RpcTest` skips codec encoding/decoding, so the value in the client IS the
132
- // wrapped object produced by `InvalidationMiddlewareLive`, even when the declared
133
- // schema is the plain type.
134
- type CommandResult = { payload: unknown; metadata: { invalidateQueries: ReadonlyArray<Invalidation.InvalidationKey> } }
135
-
136
- const isCommandResult = (value: unknown): value is CommandResult =>
137
- typeof value === "object"
138
- && value !== null
139
- && "payload" in value
140
- && "metadata" in value
141
- && typeof (value as Record<string, unknown>)["metadata"] === "object"
142
- && (value as Record<string, unknown>)["metadata"] !== null
143
- && "invalidateQueries" in ((value as Record<string, unknown>)["metadata"] as object)
144
-
145
- const asCommand = (value: unknown): CommandResult => {
146
- if (!isCommandResult(value)) throw new Error(`Expected a wrapped command result, got: ${JSON.stringify(value)}`)
147
- return value
35
+ class AppMiddleware extends MiddlewareMaker
36
+ .Tag<AppMiddleware>()("AppMiddleware", RequestContextMap)
37
+ .middleware(RequireRoles, Test)
38
+ .middleware(AllowAnonymous)
39
+ .middleware(SomeElseMiddleware)
40
+ .middleware(...DefaultGenericMiddlewares)
41
+ {
42
+ static Default = this.layer.pipe(
43
+ Layer.provide([
44
+ RequireRolesLive.pipe(Layer.provide(SomeService.Default)),
45
+ AllowAnonymousLive,
46
+ TestLive,
47
+ SomeElseMiddlewareLive,
48
+ DefaultGenericMiddlewaresLive
49
+ ])
50
+ )
148
51
  }
149
52
 
53
+ const { Router, matchAll } = makeRouter(AppMiddleware)
54
+
150
55
  // ---------------------------------------------------------------------------
151
- // Tests
56
+ // Resources
152
57
  // ---------------------------------------------------------------------------
153
58
 
154
- it.live(
155
- "query returns the correct value",
156
- Effect.fnUntraced(function*() {
157
- const client = yield* RpcTest.makeClient(E2eRpcs)
158
- const result = yield* client.getGreeting({ name: "World" })
159
- expect(result).toBe("Hello, World!")
160
- }, Effect.provide(E2eTestLayer))
161
- )
59
+ const DynamicKey: Invalidation.InvalidationKey = ["dynamic", "key"]
60
+ const ExtraKey: Invalidation.InvalidationKey = ["extra", "key"]
61
+ const StreamKey: Invalidation.InvalidationKey = ["stream", "key"]
162
62
 
163
- it.live(
164
- "query result is NOT wrapped in CommandResponseWithMetaData",
165
- Effect.fnUntraced(function*() {
166
- const client = yield* RpcTest.makeClient(E2eRpcs)
167
- const result = yield* client.getGreeting({ name: "Check" })
168
- expect(typeof result).toBe("string")
169
- expect(isCommandResult(result)).toBe(false)
170
- }, Effect.provide(E2eTestLayer))
171
- )
63
+ const { TaggedRequestFor } = makeRpcClient(RequestContextMap)
64
+ const Req = TaggedRequestFor("Inv")
172
65
 
173
- it.live(
174
- "command with no invalidation keys has empty invalidateQueries in metadata",
175
- Effect.fnUntraced(function*() {
176
- const client = yield* RpcTest.makeClient(E2eRpcs)
177
- const result = asCommand(yield* client.doNothing())
178
- expect(result.metadata.invalidateQueries).toStrictEqual([])
179
- }, Effect.provide(E2eTestLayer))
180
- )
66
+ class CmdBoom extends TaggedErrorClass<CmdBoom>()("CmdBoom", { reason: S.String }) {}
181
67
 
182
- it.live(
183
- "command with static Invalidates annotation propagates key to metadata",
184
- Effect.fnUntraced(function*() {
185
- const client = yield* RpcTest.makeClient(E2eRpcs)
186
- const result = asCommand(yield* client.doWithStaticKey())
187
- expect(result.payload).toStrictEqual({ count: 42 })
188
- expect(result.metadata.invalidateQueries).toStrictEqual([StaticKey])
189
- }, Effect.provide(E2eTestLayer))
190
- )
68
+ class DoNothing extends Req.Command<DoNothing>()("DoNothing", {}, {
69
+ allowAnonymous: true,
70
+ success: S.Void
71
+ }) {}
191
72
 
192
- it.live(
193
- "command with dynamic InvalidationSet.use propagates key to metadata",
194
- Effect.fnUntraced(function*() {
195
- const client = yield* RpcTest.makeClient(E2eRpcs)
196
- const result = asCommand(yield* client.doWithDynamicKey())
197
- expect(result.payload).toBe("done")
198
- expect(result.metadata.invalidateQueries).toStrictEqual([DynamicKey])
199
- }, Effect.provide(E2eTestLayer))
200
- )
73
+ class DoWithDynamicKey extends Req.Command<DoWithDynamicKey>()("DoWithDynamicKey", {}, {
74
+ allowAnonymous: true,
75
+ success: S.String
76
+ }) {}
201
77
 
202
- it.live(
203
- "command combining static annotation + dynamic key merges all into metadata",
204
- Effect.fnUntraced(function*() {
205
- const client = yield* RpcTest.makeClient(E2eRpcs)
206
- const result = asCommand(yield* client.doWithBothKeys())
207
- expect(result.payload).toBe(99)
208
- expect(result.metadata.invalidateQueries).toStrictEqual([StaticKey, DynamicKey])
209
- }, Effect.provide(E2eTestLayer))
210
- )
78
+ class DoWithBothKeys extends Req.Command<DoWithBothKeys>()("DoWithBothKeys", {}, {
79
+ allowAnonymous: true,
80
+ success: S.Number
81
+ }) {}
211
82
 
212
- it.live(
213
- "stream RPC without input emits all values",
214
- Effect.fnUntraced(function*() {
215
- const client = yield* RpcTest.makeClient(E2eRpcs)
216
- const values = yield* Stream.runCollect(client.streamTicks())
217
- expect(values).toStrictEqual([1, 2, 3])
218
- }, Effect.provide(E2eTestLayer))
219
- )
83
+ class DoAndFail extends Req.Command<DoAndFail>()("DoAndFail", {}, {
84
+ allowAnonymous: true,
85
+ success: S.Void,
86
+ error: CmdBoom
87
+ }) {}
220
88
 
221
- it.live(
222
- "stream RPC with input emits values driven by payload",
223
- Effect.fnUntraced(function*() {
224
- const client = yield* RpcTest.makeClient(E2eRpcs)
225
- const values = yield* Stream.runCollect(client.streamCountTo({ to: 4 }))
226
- expect(values).toStrictEqual([1, 2, 3, 4])
227
- }, Effect.provide(E2eTestLayer))
228
- )
89
+ class StreamWithKey extends Req.Command<StreamWithKey>()("StreamWithKey", {}, {
90
+ stream: true,
91
+ allowAnonymous: true,
92
+ success: S.Number
93
+ }) {}
229
94
 
230
- it.live(
231
- "per-request isolation: each command call has a fresh InvalidationSet",
232
- Effect.fnUntraced(function*() {
233
- const client = yield* RpcTest.makeClient(E2eRpcs)
234
- const r1 = asCommand(yield* client.doWithDynamicKey())
235
- const r2 = asCommand(yield* client.doWithDynamicKey())
236
- // Each call must have exactly one key — no accumulation from prior calls
237
- expect(r1.metadata.invalidateQueries).toStrictEqual([DynamicKey])
238
- expect(r2.metadata.invalidateQueries).toStrictEqual([DynamicKey])
239
- }, Effect.provide(E2eTestLayer))
240
- )
95
+ const InvRsc = { DoNothing, DoWithDynamicKey, DoWithBothKeys, DoAndFail, StreamWithKey }
241
96
 
242
97
  // ---------------------------------------------------------------------------
243
- // Client-side consumption tests
244
- //
245
- // These tests verify the full roundtrip for cache invalidation:
246
- // 1. The server wraps command results in `{ payload, metadata: { invalidateQueries } }`.
247
- // 2. The client-side `unwrapCommand` logic (as implemented in `apiClientFactory`) extracts
248
- // the payload and forwards each key to `InvalidationKeysFromServer.add()`.
249
- //
250
- // `runAndCapture` replicates that logic using a fresh `Ref`-backed service so the test
251
- // can inspect which keys the client received after the command completes.
98
+ // Controllers / router
252
99
  // ---------------------------------------------------------------------------
253
100
 
254
- /**
255
- * Replicates `apiClientFactory`'s `unwrapCommand` + `InvalidationKeysFromServer` pattern:
256
- * - Unwraps the `CommandResponseWithMetaData` result
257
- * - Calls `svc.add(key)` for every key in `metadata.invalidateQueries`
258
- * - Returns `{ payload, keys }` for assertion
259
- */
260
- const runAndCapture = <A, E, R>(eff: Effect.Effect<A, E, R>) =>
261
- Effect.gen(function*() {
262
- const keysRef = yield* Ref.make<ReadonlyArray<Invalidation.InvalidationKey>>([])
263
- const svc = makeInvalidationKeysService(keysRef)
264
- const cmd = asCommand(yield* eff)
265
- yield* Effect.forEach(cmd.metadata.invalidateQueries, svc.add, { discard: true })
266
- return { payload: cmd.payload, keys: yield* Ref.get(keysRef) }
267
- })
268
-
269
- it.live(
270
- "client consumes static key: payload unwrapped and key forwarded to InvalidationKeysFromServer",
271
- Effect.fnUntraced(function*() {
272
- const client = yield* RpcTest.makeClient(E2eRpcs)
273
- const { payload, keys } = yield* runAndCapture(client.doWithStaticKey())
274
- expect(payload).toStrictEqual({ count: 42 })
275
- expect(keys).toStrictEqual([StaticKey])
276
- }, Effect.provide(E2eTestLayer))
277
- )
278
-
279
- it.live(
280
- "client consumes dynamic key: payload unwrapped and key forwarded to InvalidationKeysFromServer",
281
- Effect.fnUntraced(function*() {
282
- const client = yield* RpcTest.makeClient(E2eRpcs)
283
- const { payload, keys } = yield* runAndCapture(client.doWithDynamicKey())
284
- expect(payload).toBe("done")
285
- expect(keys).toStrictEqual([DynamicKey])
286
- }, Effect.provide(E2eTestLayer))
287
- )
101
+ const router = Router(InvRsc)({
102
+ *effect(match) {
103
+ return match({
104
+ DoNothing: () => Effect.void,
105
+ DoWithDynamicKey: Effect.fnUntraced(function*() {
106
+ yield* Invalidation.InvalidationSet.use((_) => _.add(DynamicKey))
107
+ return "done"
108
+ }),
109
+ DoWithBothKeys: Effect.fnUntraced(function*() {
110
+ yield* Invalidation.InvalidationSet.use((_) => _.add(DynamicKey))
111
+ yield* Invalidation.InvalidationSet.use((_) => _.add(ExtraKey))
112
+ return 99
113
+ }),
114
+ DoAndFail: Effect.fnUntraced(function*() {
115
+ yield* Invalidation.InvalidationSet.use((_) => _.add(DynamicKey))
116
+ return yield* Effect.fail(new CmdBoom({ reason: "intentional failure" }))
117
+ }),
118
+ StreamWithKey: () =>
119
+ Stream.fromIterable([1, 2, 3]).pipe(
120
+ Stream.tap(() => Invalidation.InvalidationSet.use((_) => _.add(StreamKey)))
121
+ )
122
+ })
123
+ }
124
+ })
288
125
 
289
- it.live(
290
- "client consumes combined static+dynamic keys: all keys forwarded to InvalidationKeysFromServer",
291
- Effect.fnUntraced(function*() {
292
- const client = yield* RpcTest.makeClient(E2eRpcs)
293
- const { payload, keys } = yield* runAndCapture(client.doWithBothKeys())
294
- expect(payload).toBe(99)
295
- expect(keys).toStrictEqual([StaticKey, DynamicKey])
296
- }, Effect.provide(E2eTestLayer))
297
- )
126
+ const RpcRouterLayer = matchAll({ router })
298
127
 
299
128
  // ---------------------------------------------------------------------------
300
- // Stream metadata tests (V1)
301
- //
302
- // These tests verify the stream chunk wrapping: the routing layer wraps each
303
- // emitted value as `{ _tag: "value", value }` and appends a final
304
- // `{ _tag: "done", metadata: { invalidateQueries } }` chunk. The client
305
- // side filters out "done" chunks (accumulating keys) and maps "value" chunks
306
- // to extract the payload. The tests below exercise both layers independently
307
- // using an RPC group whose success schema is `StreamResponseChunk(S.Number)`.
129
+ // HTTP wiring fresh server on loopback per `it.live`.
308
130
  // ---------------------------------------------------------------------------
309
131
 
310
- const StreamMetaRpcs = RpcGroup.make(
311
- // Stream that emits plain numbers — the server wraps them as StreamResponseChunk items
312
- Rpc
313
- .make("streamWithMeta", {
314
- success: Invalidation.StreamResponseChunk(S.Number),
315
- stream: true
132
+ const NodeServerLayer = NodeHttpServer.layer(() => createServer(), { port: 0 })
133
+
134
+ const ServerLayer = HttpRouter
135
+ .serve(RpcRouterLayer)
136
+ .pipe(
137
+ Layer.provide(NodeServerLayer),
138
+ Layer.provide(RpcSerialization.layerNdjson)
139
+ )
140
+
141
+ const ClientLayer = Layer
142
+ .unwrap(
143
+ Effect.gen(function*() {
144
+ const server = yield* HttpServer.HttpServer
145
+ const addr = server.address
146
+ if (addr._tag !== "TcpAddress") return yield* Effect.die(new Error("expected TcpAddress"))
147
+ const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname
148
+ const url = `http://${host}:${addr.port}`
149
+ return ApiClientFactory
150
+ .layer({ url, headers: Option.none() })
151
+ .pipe(Layer.provide(FetchHttpClient.layer))
316
152
  })
317
- .annotate(RequestType, "query")
318
- .middleware(InvalidationMiddleware)
319
- )
153
+ )
154
+ .pipe(Layer.provide(NodeServerLayer))
320
155
 
321
- const StreamKey: Invalidation.InvalidationKey = ["stream", "key"]
156
+ const TestLayer = Layer.mergeAll(ServerLayer, ClientLayer)
322
157
 
323
- const StreamMetaImplLayer = StreamMetaRpcs.toLayer({
324
- // Handler returns pre-wrapped chunks: simulates what routing.ts produces
325
- streamWithMeta: () =>
326
- Stream.fromIterable([
327
- { _tag: "value" as const, value: 1 },
328
- { _tag: "value" as const, value: 2 },
329
- { _tag: "value" as const, value: 3 },
330
- { _tag: "done" as const, metadata: { invalidateQueries: [StreamKey] } }
331
- ])
332
- })
158
+ // Helper: provide a fresh `InvalidationKeysFromServer` and capture forwarded keys.
159
+ const withCapture = <A, E, R>(eff: Effect.Effect<A, E, R>) =>
160
+ Effect.gen(function*() {
161
+ const ref = yield* Ref.make<ReadonlyArray<Invalidation.InvalidationKey>>([])
162
+ const svc = makeInvalidationKeysService(ref)
163
+ const result = yield* eff.pipe(Effect.provideService(InvalidationKeysFromServer, svc), Effect.exit)
164
+ return { result, keys: yield* Ref.get(ref) }
165
+ })
333
166
 
334
- const StreamMetaTestLayer = Layer.merge(StreamMetaImplLayer, InvalidationMiddlewareLive)
167
+ // ---------------------------------------------------------------------------
168
+ // Tests
169
+ // ---------------------------------------------------------------------------
335
170
 
336
171
  it.live(
337
- "stream: client-side unwrapping delivers plain values and discards 'done' chunk",
172
+ "command with no invalidation keys: caller sees raw payload, no keys forwarded",
338
173
  Effect.fnUntraced(function*() {
339
- const client = yield* RpcTest.makeClient(StreamMetaRpcs)
340
- const raw = yield* Stream.runCollect(client.streamWithMeta())
341
- // Client must filter out the "done" chunk and extract only values
342
- const values = raw
343
- .filter((item: any) => item._tag === "value")
344
- .map((item: any) => item.value)
345
- expect(values).toStrictEqual([1, 2, 3])
346
- }, Effect.provide(StreamMetaTestLayer))
174
+ const client = yield* ApiClientFactory.makeFor(Layer.empty, { middleware: AppMiddleware })(InvRsc)
175
+ const { result, keys } = yield* withCapture(client.DoNothing.handler())
176
+ expect(Exit.isSuccess(result)).toBe(true)
177
+ expect(keys).toStrictEqual([])
178
+ }, Effect.provide(TestLayer)),
179
+ { timeout: 10_000 }
347
180
  )
348
181
 
349
182
  it.live(
350
- "stream: client-side invalidation keys are collected from the 'done' chunk",
183
+ "command with dynamic InvalidationSet.use: payload + key forwarded",
351
184
  Effect.fnUntraced(function*() {
352
- const keysRef = yield* Ref.make<ReadonlyArray<Invalidation.InvalidationKey>>([])
353
- const svc = makeInvalidationKeysService(keysRef)
354
- const client = yield* RpcTest.makeClient(StreamMetaRpcs)
355
- const raw = yield* Stream.runCollect(client.streamWithMeta())
356
- // Simulate what buildStream does: tap "done" items to accumulate keys
357
- for (const item of raw) {
358
- if ((item as any)._tag === "done") {
359
- const meta = (item as any).metadata as Invalidation.CommandMetaData
360
- yield* Effect.forEach(meta.invalidateQueries, svc.add, { discard: true })
361
- }
362
- }
363
- const keys = yield* Ref.get(keysRef)
364
- expect(keys).toStrictEqual([StreamKey])
365
- }, Effect.provide(StreamMetaTestLayer))
185
+ const client = yield* ApiClientFactory.makeFor(Layer.empty, { middleware: AppMiddleware })(InvRsc)
186
+ const { result, keys } = yield* withCapture(client.DoWithDynamicKey.handler())
187
+ expect(Exit.isSuccess(result) && result.value).toBe("done")
188
+ expect(keys).toStrictEqual([DynamicKey])
189
+ }, Effect.provide(TestLayer)),
190
+ { timeout: 10_000 }
366
191
  )
367
192
 
368
193
  it.live(
369
- "stream: InvalidationKeysFromServer receives keys from 'done' chunk via buildStream-style tap",
194
+ "command accumulating multiple dynamic keys: all keys forwarded in order",
370
195
  Effect.fnUntraced(function*() {
371
- const keysRef = yield* Ref.make<ReadonlyArray<Invalidation.InvalidationKey>>([])
372
- const invKeys = makeInvalidationKeysService(keysRef)
373
- const client = yield* RpcTest.makeClient(StreamMetaRpcs)
374
- // Replicate the buildStream processing pipeline: tap must run in the same fiber
375
- // context as the InvalidationKeysFromServer provider, so we use Effect.provideService
376
- // (fiber-level) rather than Stream.provideService (element-level) to ensure the
377
- // tap's Effect.use call resolves invKeys.
378
- const values = yield* client.streamWithMeta().pipe(
379
- Stream.tap((item: any) =>
380
- item._tag === "done"
381
- ? InvalidationKeysFromServer.use((s) =>
382
- Effect.forEach(
383
- (item.metadata as Invalidation.CommandMetaData).invalidateQueries,
384
- s.add,
385
- { discard: true }
386
- )
387
- )
388
- : Effect.void
389
- ),
390
- Stream.filter((item: any) => item._tag === "value"),
391
- Stream.map((item: any) => item.value),
392
- Stream.runCollect,
393
- Effect.provideService(InvalidationKeysFromServer, invKeys)
394
- )
395
- const keys = yield* Ref.get(keysRef)
396
- expect(values).toStrictEqual([1, 2, 3])
397
- expect(keys).toStrictEqual([StreamKey])
398
- }, Effect.provide(StreamMetaTestLayer))
196
+ const client = yield* ApiClientFactory.makeFor(Layer.empty, { middleware: AppMiddleware })(InvRsc)
197
+ const { result, keys } = yield* withCapture(client.DoWithBothKeys.handler())
198
+ expect(Exit.isSuccess(result) && result.value).toBe(99)
199
+ expect(keys).toStrictEqual([DynamicKey, ExtraKey])
200
+ }, Effect.provide(TestLayer)),
201
+ { timeout: 10_000 }
399
202
  )
400
203
 
401
- // ---------------------------------------------------------------------------
402
- // V2 tests — invalidation keys included in failures
403
- // ---------------------------------------------------------------------------
404
-
405
204
  it.live(
406
- "V2: command failure includes accumulated keys in CommandFailureWithMetaData",
205
+ "per-request isolation: each command call starts with a fresh InvalidationSet",
407
206
  Effect.fnUntraced(function*() {
408
- const client = yield* RpcTest.makeClient(E2eRpcs)
409
- const exit = yield* Effect.exit(client.doAndFail())
410
- // Should fail with CommandFailureWithMetaData wrapping the original error
411
- if (exit._tag === "Success") throw new Error("Expected failure")
412
- const err = (exit.cause as any).reasons?.[0]?.error
413
- expect(err?._tag).toBe("CommandFailureWithMetaData")
414
- expect(err?.error).toStrictEqual({ message: "intentional failure" })
415
- expect(err?.metadata?.invalidateQueries).toStrictEqual([StaticKey, DynamicKey])
416
- }, Effect.provide(E2eTestLayer))
207
+ const client = yield* ApiClientFactory.makeFor(Layer.empty, { middleware: AppMiddleware })(InvRsc)
208
+ const r1 = yield* withCapture(client.DoWithDynamicKey.handler())
209
+ const r2 = yield* withCapture(client.DoWithDynamicKey.handler())
210
+ // Each call must have exactly one key — no accumulation across calls
211
+ expect(r1.keys).toStrictEqual([DynamicKey])
212
+ expect(r2.keys).toStrictEqual([DynamicKey])
213
+ }, Effect.provide(TestLayer)),
214
+ { timeout: 10_000 }
417
215
  )
418
216
 
419
217
  it.live(
420
- "V2: client unwraps CommandFailureWithMetaData re-fails with original error and forwards keys",
218
+ "command failure (V2): keys accumulated before fail still reach the client; original error re-thrown",
421
219
  Effect.fnUntraced(function*() {
422
- const keysRef = yield* Ref.make<ReadonlyArray<Invalidation.InvalidationKey>>([])
423
- const svc = makeInvalidationKeysService(keysRef)
424
- const client = yield* RpcTest.makeClient(E2eRpcs)
425
-
426
- // Simulate apiClientFactory unwrapCommand: catch CommandFailureWithMetaData,
427
- // forward keys, re-fail with the original error.
428
- const exit = yield* Effect.exit(
429
- client.doAndFail().pipe(
430
- Effect.catch((err: any) =>
431
- err?._tag === "CommandFailureWithMetaData"
432
- ? Effect
433
- .forEach(
434
- (err.metadata?.invalidateQueries ?? []) as ReadonlyArray<Invalidation.InvalidationKey>,
435
- svc.add,
436
- { discard: true }
437
- )
438
- .pipe(Effect.flatMap(() => Effect.fail(err.error)))
439
- : Effect.fail(err)
440
- ),
441
- Effect.provideService(InvalidationKeysFromServer, svc)
442
- )
443
- )
444
-
445
- const keys = yield* Ref.get(keysRef)
446
- if (exit._tag === "Success") throw new Error("Expected failure")
447
- const originalErr = (exit.cause as any).reasons?.[0]?.error
448
- expect(originalErr).toStrictEqual({ message: "intentional failure" })
449
- expect(keys).toStrictEqual([StaticKey, DynamicKey])
450
- }, Effect.provide(E2eTestLayer))
451
- )
452
-
453
- const StreamMetaV2Rpcs = RpcGroup.make(
454
- // Stream with pre-wrapped failure chunk — simulates routing.ts V2 failure output
455
- Rpc
456
- .make("streamWithFailure", {
457
- success: Invalidation.StreamResponseChunk(S.Number),
458
- error: Invalidation.StreamFailureChunk(S.Struct({ msg: S.String })),
459
- stream: true
460
- })
461
- .annotate(RequestType, "query")
462
- .middleware(InvalidationMiddleware)
220
+ const client = yield* ApiClientFactory.makeFor(Layer.empty, { middleware: AppMiddleware })(InvRsc)
221
+ const { result, keys } = yield* withCapture(client.DoAndFail.handler())
222
+ expect(Exit.isFailure(result)).toBe(true)
223
+ if (Exit.isFailure(result)) {
224
+ const failures = (result.cause as any).reasons as ReadonlyArray<{ _tag: "Fail"; error: any }>
225
+ expect(failures[0]?.error?._tag).toBe("CmdBoom")
226
+ expect(failures[0]?.error?.reason).toBe("intentional failure")
227
+ }
228
+ expect(keys).toStrictEqual([DynamicKey])
229
+ }, Effect.provide(TestLayer)),
230
+ { timeout: 10_000 }
463
231
  )
464
232
 
465
- const StreamV2Key: Invalidation.InvalidationKey = ["stream-v2", "key"]
466
-
467
- const StreamMetaV2ImplLayer = StreamMetaV2Rpcs.toLayer({
468
- // Emits two values then fails with a StreamFailureChunk — simulates routing.ts wrapping
469
- streamWithFailure: () =>
470
- Stream.concat(
471
- Stream.fromIterable([
472
- { _tag: "value" as const, value: 1 },
473
- { _tag: "value" as const, value: 2 }
474
- ]),
475
- Stream.fromEffect(
476
- Effect.fail({
477
- _tag: "error" as const,
478
- error: { msg: "stream error" },
479
- metadata: { invalidateQueries: [StreamV2Key] }
480
- })
481
- )
482
- )
483
- })
484
-
485
- const StreamMetaV2TestLayer = Layer.merge(StreamMetaV2ImplLayer, InvalidationMiddlewareLive)
486
-
487
233
  it.live(
488
- "V2: stream failure chunk carries accumulated keys and original error",
234
+ "stream: per-chunk metadata drains keys mid-stream",
489
235
  Effect.fnUntraced(function*() {
490
- const client = yield* RpcTest.makeClient(StreamMetaV2Rpcs)
491
- const chunks: Array<any> = []
492
- const exit = yield* Effect.exit(
493
- Stream.runForEach(client.streamWithFailure(), (item) =>
494
- Effect.sync(() => {
495
- chunks.push(item)
496
- }))
236
+ const client = yield* ApiClientFactory.makeFor(Layer.empty, { middleware: AppMiddleware })(InvRsc)
237
+ const ref = yield* Ref.make<ReadonlyArray<Invalidation.InvalidationKey>>([])
238
+ const svc = makeInvalidationKeysService(ref)
239
+ const values = yield* Stream.runCollect(client.StreamWithKey.handler()).pipe(
240
+ Effect.provideService(InvalidationKeysFromServer, svc)
497
241
  )
498
- // Two value chunks should have been seen before the failure
499
- expect(chunks.map((c: any) => c.value)).toStrictEqual([1, 2])
500
- // The stream should fail with the StreamFailureChunk
501
- if (exit._tag === "Success") throw new Error("Expected failure")
502
- const err = (exit.cause as any).reasons?.[0]?.error
503
- expect(err?._tag).toBe("error")
504
- expect(err?.error).toStrictEqual({ msg: "stream error" })
505
- expect(err?.metadata?.invalidateQueries).toStrictEqual([StreamV2Key])
506
- }, Effect.provide(StreamMetaV2TestLayer))
242
+ const keys = yield* Ref.get(ref)
243
+ expect(values).toStrictEqual([1, 2, 3])
244
+ // Handler taps `InvalidationSet.use` once per emitted value; routing's V3 mid-stream
245
+ // metadata drain forwards each batch as it arrives.
246
+ expect(keys).toStrictEqual([StreamKey, StreamKey, StreamKey])
247
+ }, Effect.provide(TestLayer)),
248
+ { timeout: 10_000 }
507
249
  )