@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.
- package/CHANGELOG.md +32 -0
- package/dist/api/routing/middleware/middleware.d.ts +3 -13
- package/dist/api/routing/middleware/middleware.d.ts.map +1 -1
- package/dist/api/routing/middleware/middleware.js +5 -35
- package/dist/api/routing.d.ts +1 -1
- package/dist/api/routing.d.ts.map +1 -1
- package/dist/api/routing.js +18 -2
- package/package.json +2 -2
- package/src/api/routing/middleware/middleware.ts +5 -61
- package/src/api/routing.ts +32 -1
- package/test/controller.test.ts +1 -1
- package/test/dist/controller.test.d.ts.map +1 -1
- package/test/dist/date-query.test.d.ts.map +1 -0
- package/test/router-generator.test.ts +4 -1
- package/test/rpc-e2e-invalidation.test.ts +188 -446
- package/test/rpc-stream-fullstack.test.ts +9 -9
|
@@ -1,507 +1,249 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* E2E tests for
|
|
2
|
+
* E2E tests for the invalidation key flow exercised end-to-end via the
|
|
3
|
+
* production wrap/unwrap path:
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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 {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
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
|
-
//
|
|
32
|
+
// Middleware (mirrors AppMiddleware shape — same composite as other e2e tests).
|
|
23
33
|
// ---------------------------------------------------------------------------
|
|
24
34
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
//
|
|
56
|
+
// Resources
|
|
152
57
|
// ---------------------------------------------------------------------------
|
|
153
58
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
*
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
)
|
|
153
|
+
)
|
|
154
|
+
.pipe(Layer.provide(NodeServerLayer))
|
|
320
155
|
|
|
321
|
-
const
|
|
156
|
+
const TestLayer = Layer.mergeAll(ServerLayer, ClientLayer)
|
|
322
157
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Tests
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
335
170
|
|
|
336
171
|
it.live(
|
|
337
|
-
"
|
|
172
|
+
"command with no invalidation keys: caller sees raw payload, no keys forwarded",
|
|
338
173
|
Effect.fnUntraced(function*() {
|
|
339
|
-
const client = yield*
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
"
|
|
183
|
+
"command with dynamic InvalidationSet.use: payload + key forwarded",
|
|
351
184
|
Effect.fnUntraced(function*() {
|
|
352
|
-
const
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
"
|
|
194
|
+
"command accumulating multiple dynamic keys: all keys forwarded in order",
|
|
370
195
|
Effect.fnUntraced(function*() {
|
|
371
|
-
const
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
"
|
|
205
|
+
"per-request isolation: each command call starts with a fresh InvalidationSet",
|
|
407
206
|
Effect.fnUntraced(function*() {
|
|
408
|
-
const client = yield*
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
expect(
|
|
414
|
-
|
|
415
|
-
|
|
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:
|
|
218
|
+
"command failure (V2): keys accumulated before fail still reach the client; original error re-thrown",
|
|
421
219
|
Effect.fnUntraced(function*() {
|
|
422
|
-
const
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
"
|
|
234
|
+
"stream: per-chunk metadata drains keys mid-stream",
|
|
489
235
|
Effect.fnUntraced(function*() {
|
|
490
|
-
const client = yield*
|
|
491
|
-
const
|
|
492
|
-
const
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
499
|
-
expect(
|
|
500
|
-
//
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
)
|