effect 4.0.0-beta.23 → 4.0.0-beta.25

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.
Files changed (41) hide show
  1. package/dist/Effect.d.ts +1 -1
  2. package/dist/Effect.d.ts.map +1 -1
  3. package/dist/Schema.d.ts +21 -0
  4. package/dist/Schema.d.ts.map +1 -1
  5. package/dist/Schema.js +61 -17
  6. package/dist/Schema.js.map +1 -1
  7. package/dist/SchemaAST.d.ts.map +1 -1
  8. package/dist/SchemaAST.js +26 -14
  9. package/dist/SchemaAST.js.map +1 -1
  10. package/dist/SchemaRepresentation.d.ts.map +1 -1
  11. package/dist/SchemaRepresentation.js +2 -0
  12. package/dist/SchemaRepresentation.js.map +1 -1
  13. package/dist/ServiceMap.d.ts +1 -0
  14. package/dist/ServiceMap.d.ts.map +1 -1
  15. package/dist/ServiceMap.js.map +1 -1
  16. package/dist/internal/effect.js.map +1 -1
  17. package/dist/internal/schema/representation.js +1 -1
  18. package/dist/internal/schema/representation.js.map +1 -1
  19. package/dist/testing/TestClock.d.ts +2 -2
  20. package/dist/unstable/http/HttpClient.d.ts +80 -2
  21. package/dist/unstable/http/HttpClient.d.ts.map +1 -1
  22. package/dist/unstable/http/HttpClient.js +179 -1
  23. package/dist/unstable/http/HttpClient.js.map +1 -1
  24. package/dist/unstable/httpapi/HttpApi.d.ts +1 -1
  25. package/dist/unstable/httpapi/HttpApi.d.ts.map +1 -1
  26. package/dist/unstable/rpc/RpcServer.d.ts +2 -2
  27. package/dist/unstable/rpc/RpcServer.d.ts.map +1 -1
  28. package/dist/unstable/sql/SqlResolver.d.ts.map +1 -1
  29. package/dist/unstable/sql/SqlResolver.js +15 -6
  30. package/dist/unstable/sql/SqlResolver.js.map +1 -1
  31. package/package.json +1 -1
  32. package/src/Effect.ts +1 -1
  33. package/src/Schema.ts +98 -18
  34. package/src/SchemaAST.ts +24 -19
  35. package/src/SchemaRepresentation.ts +2 -0
  36. package/src/ServiceMap.ts +1 -0
  37. package/src/internal/effect.ts +2 -1
  38. package/src/internal/schema/representation.ts +1 -0
  39. package/src/unstable/http/HttpClient.ts +306 -3
  40. package/src/unstable/httpapi/HttpApi.ts +1 -1
  41. package/src/unstable/sql/SqlResolver.ts +15 -5
@@ -4950,7 +4950,8 @@ export const forkScoped: {
4950
4950
  readonly startImmediately?: boolean | undefined
4951
4951
  readonly uninterruptible?: boolean | "inherit" | undefined
4952
4952
  } | undefined
4953
- ): [Arg] extends [Effect.Effect<infer _A, infer _E, infer _R>] ? Effect.Effect<Fiber.Fiber<_A, _E>, never, _R>
4953
+ ): [Arg] extends [Effect.Effect<infer _A, infer _E, infer _R>] ?
4954
+ Effect.Effect<Fiber.Fiber<_A, _E>, never, _R | Scope.Scope>
4954
4955
  : <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<Fiber.Fiber<A, E>, never, R | Scope.Scope>
4955
4956
  } = dual((args) => isEffect(args[0]), <A, E, R>(
4956
4957
  self: Effect.Effect<A, E, R>,
@@ -268,6 +268,7 @@ export function fromASTs(asts: readonly [AST.AST, ...Array<AST.AST>]): SchemaRep
268
268
  export const fromASTBlacklist: Set<string> = new Set([
269
269
  // `expected` is preserved because is useful to generate descriptions in JSON Schemas
270
270
  "~structural",
271
+ "~sentinels",
271
272
  "meta",
272
273
  "toArbitrary",
273
274
  "toArbitraryConstraint",
@@ -3,10 +3,12 @@
3
3
  */
4
4
  import type { NonEmptyReadonlyArray } from "../../Array.ts"
5
5
  import * as Cause from "../../Cause.ts"
6
+ import { Clock } from "../../Clock.ts"
7
+ import * as Duration from "../../Duration.ts"
6
8
  import * as Effect from "../../Effect.ts"
7
9
  import * as Exit from "../../Exit.ts"
8
- import type { Fiber } from "../../Fiber.ts"
9
- import { constFalse, constTrue, dual, flow, identity } from "../../Function.ts"
10
+ import * as Fiber from "../../Fiber.ts"
11
+ import { constant, constFalse, constTrue, dual, flow, identity } from "../../Function.ts"
10
12
  import * as Inspectable from "../../Inspectable.ts"
11
13
  import * as Layer from "../../Layer.ts"
12
14
  import { type Pipeable, pipeArguments } from "../../Pipeable.ts"
@@ -19,6 +21,7 @@ import * as ServiceMap from "../../ServiceMap.ts"
19
21
  import * as Stream from "../../Stream.ts"
20
22
  import * as Tracer from "../../Tracer.ts"
21
23
  import type { EqualsWith, ExcludeTag, ExtractTag, NoExcessProperties, NoInfer, Tags } from "../../Types.ts"
24
+ import type * as RateLimiter from "../persistence/RateLimiter.ts"
22
25
  import * as Cookies from "./Cookies.ts"
23
26
  import * as Headers from "./Headers.ts"
24
27
  import * as Error from "./HttpClientError.ts"
@@ -630,7 +633,7 @@ export const make = (
630
633
  request: HttpClientRequest.HttpClientRequest,
631
634
  url: URL,
632
635
  signal: AbortSignal,
633
- fiber: Fiber<HttpClientResponse.HttpClientResponse, Error.HttpClientError>
636
+ fiber: Fiber.Fiber<HttpClientResponse.HttpClientResponse, Error.HttpClientError>
634
637
  ) => Effect.Effect<HttpClientResponse.HttpClientResponse, Error.HttpClientError>
635
638
  ): HttpClient =>
636
639
  makeWith((effect) =>
@@ -1072,6 +1075,301 @@ export const retryTransient: {
1072
1075
  }
1073
1076
  )
1074
1077
 
1078
+ /**
1079
+ * @since 4.0.0
1080
+ * @category rate limiting
1081
+ */
1082
+ export declare namespace WithRateLimiter {
1083
+ /**
1084
+ * @since 4.0.0
1085
+ * @category rate limiting
1086
+ */
1087
+ export interface Options {
1088
+ /**
1089
+ * The `RateLimiter` service to use for rate limiting.
1090
+ */
1091
+ readonly limiter: RateLimiter.RateLimiter
1092
+ /**
1093
+ * The initial rate limit window duration.
1094
+ */
1095
+ readonly window: Duration.Input
1096
+ /**
1097
+ * The initial maximum number of allowed requests in the window.
1098
+ */
1099
+ readonly limit: number
1100
+ /**
1101
+ * The key to identify the rate limit. Requests with the same key will share
1102
+ * the same rate limit. This can be used to implement per-user or
1103
+ * per-endpoint rate limits.
1104
+ */
1105
+ readonly key: string | ((request: HttpClientRequest.HttpClientRequest) => string)
1106
+ /**
1107
+ * Defaults to `"fixed-window"`.
1108
+ */
1109
+ readonly algorithm?: "fixed-window" | "token-bucket" | undefined
1110
+ /**
1111
+ * Defaults to `1`.
1112
+ */
1113
+ readonly tokens?: number | ((request: HttpClientRequest.HttpClientRequest) => number) | undefined
1114
+ /**
1115
+ * Disable automatic limits updates from response headers.
1116
+ */
1117
+ readonly disableResponseInspection?: boolean | undefined
1118
+ }
1119
+ }
1120
+
1121
+ /**
1122
+ * Applies request rate limiting using the `RateLimiter` service.
1123
+ *
1124
+ * It can update limits by inspecting common rate limit response headers and
1125
+ * automatically retries HTTP `429` responses (or `HttpClientError` values
1126
+ * wrapping a `429` response) by forcing the retry back through the limiter.
1127
+ *
1128
+ * @since 4.0.0
1129
+ * @category rate limiting
1130
+ */
1131
+ export const withRateLimiter: {
1132
+ /**
1133
+ * Applies request rate limiting using the `RateLimiter` service.
1134
+ *
1135
+ * It can update limits by inspecting common rate limit response headers and
1136
+ * automatically retries HTTP `429` responses (or `HttpClientError` values
1137
+ * wrapping a `429` response) by forcing the retry back through the limiter.
1138
+ *
1139
+ * @since 4.0.0
1140
+ * @category rate limiting
1141
+ */
1142
+ (options: WithRateLimiter.Options): <E, R>(
1143
+ self: HttpClient.With<E, R>
1144
+ ) => HttpClient.With<E | RateLimiter.RateLimiterError, R>
1145
+ /**
1146
+ * Applies request rate limiting using the `RateLimiter` service.
1147
+ *
1148
+ * It can update limits by inspecting common rate limit response headers and
1149
+ * automatically retries HTTP `429` responses (or `HttpClientError` values
1150
+ * wrapping a `429` response) by forcing the retry back through the limiter.
1151
+ *
1152
+ * @since 4.0.0
1153
+ * @category rate limiting
1154
+ */
1155
+ <E, R>(self: HttpClient.With<E, R>, options: WithRateLimiter.Options): HttpClient.With<E | RateLimiter.RateLimiterError, R>
1156
+ } = dual(2, <E, R>(
1157
+ self: HttpClient.With<E, R>,
1158
+ options: WithRateLimiter.Options
1159
+ ): HttpClient.With<E | RateLimiter.RateLimiterError, R> => {
1160
+ const initialState: RateLimiterState = {
1161
+ initial: true,
1162
+ limit: options.limit,
1163
+ window: Duration.max(Duration.fromInputUnsafe(options.window), Duration.millis(1))
1164
+ }
1165
+ const states = new Map<string, RateLimiterState>()
1166
+
1167
+ const keyOption = options.key
1168
+ const resolveKey: (request: HttpClientRequest.HttpClientRequest) => string = typeof keyOption === "function"
1169
+ ? keyOption
1170
+ : constant(keyOption)
1171
+ const tokensOption = options.tokens
1172
+ const resolveTokens: (request: HttpClientRequest.HttpClientRequest) => number = typeof tokensOption === "function"
1173
+ ? tokensOption
1174
+ : constant(tokensOption ?? 1)
1175
+
1176
+ const getState = (key: string): RateLimiterState => {
1177
+ const current = states.get(key)
1178
+ if (current !== undefined) {
1179
+ return current
1180
+ }
1181
+ states.set(key, initialState)
1182
+ return initialState
1183
+ }
1184
+
1185
+ const onResponse = options.disableResponseInspection
1186
+ ? undefined
1187
+ : (clock: Clock, key: string, headers: Headers.Headers, tokens: number) => {
1188
+ const current = getState(key)
1189
+ const next = parseRateLimiterState(current, clock, headers, tokens)
1190
+ if (next.limit !== current.limit || !Duration.equals(next.window, current.window)) {
1191
+ states.set(key, next)
1192
+ }
1193
+ }
1194
+
1195
+ return transform(self, function loop(effect, request): Effect.Effect<
1196
+ HttpClientResponse.HttpClientResponse,
1197
+ E | RateLimiter.RateLimiterError,
1198
+ R
1199
+ > {
1200
+ const fiber = Fiber.getCurrent()!
1201
+ const clock = fiber.getRef(Clock)
1202
+ const key = resolveKey(request)
1203
+ const tokens = Math.max(resolveTokens(request), 1)
1204
+ const current = getState(key)
1205
+ function retry(response: HttpClientResponse.HttpClientResponse) {
1206
+ if (options.disableResponseInspection) return loop(effect, request)
1207
+ const retryAfter = parseRetryAfter(clock, getHeader(response.headers, "retry-after"))
1208
+ return retryAfter
1209
+ ? Effect.flatMap(Effect.sleep(retryAfter), () => loop(effect, request))
1210
+ : loop(effect, request)
1211
+ }
1212
+ return Effect.flatMap(
1213
+ options.limiter.consume({
1214
+ algorithm: options.algorithm,
1215
+ onExceeded: "delay",
1216
+ key,
1217
+ limit: current.limit,
1218
+ window: current.window,
1219
+ tokens
1220
+ }),
1221
+ ({ delay }) => {
1222
+ const run = Effect.matchEffect(effect, {
1223
+ onSuccess(response) {
1224
+ onResponse?.(clock, key, response.headers, tokens)
1225
+ if (response.status !== 429) return Effect.succeed(response)
1226
+ return retry(response)
1227
+ },
1228
+ onFailure(error) {
1229
+ if (isTooManyRequestsHttpClientError(error)) {
1230
+ onResponse?.(clock, key, error.reason.response.headers, tokens)
1231
+ return retry(error.reason.response)
1232
+ }
1233
+ return Effect.fail(error)
1234
+ }
1235
+ })
1236
+ return Duration.isZero(delay) ? run : Effect.delay(run, delay)
1237
+ }
1238
+ )
1239
+ })
1240
+ })
1241
+
1242
+ interface RateLimiterState {
1243
+ readonly limit: number
1244
+ readonly window: Duration.Duration
1245
+ readonly initial: boolean
1246
+ }
1247
+
1248
+ const parseRateLimiterState = (
1249
+ state: RateLimiterState,
1250
+ clock: Clock,
1251
+ headers: Headers.Headers,
1252
+ tokens: number
1253
+ ): RateLimiterState => {
1254
+ const limit = parseRateLimitLimit(state, headers, tokens) ?? state.limit
1255
+ const window = parseRateLimitWindow(clock, headers) ?? state.window
1256
+ if (limit === state.limit && Duration.equals(window, state.window)) {
1257
+ return state
1258
+ }
1259
+ return { limit, window, initial: false }
1260
+ }
1261
+
1262
+ const parseRateLimitLimit = (
1263
+ state: RateLimiterState,
1264
+ headers: Headers.Headers,
1265
+ tokens: number
1266
+ ): number | undefined => {
1267
+ const raw = getHeader(headers, "ratelimit-limit", "x-ratelimit-limit")
1268
+ const value = parseNumberHeader(raw)
1269
+ if (value !== undefined && value > 0) {
1270
+ return value
1271
+ }
1272
+ const remaining = parseRateLimitRemaining(headers)
1273
+ if (remaining === undefined) {
1274
+ return undefined
1275
+ }
1276
+ return state.initial ? remaining + tokens : Math.max(remaining + tokens, state.limit)
1277
+ }
1278
+
1279
+ const parseRateLimitRemaining = (headers: Headers.Headers): number | undefined => {
1280
+ const raw = getHeader(headers, "ratelimit-remaining", "x-ratelimit-remaining")
1281
+ const value = parseNumberHeader(raw)
1282
+ return value !== undefined && value >= 0 ? value : undefined
1283
+ }
1284
+
1285
+ const parseRateLimitWindow = (
1286
+ clock: Clock,
1287
+ headers: Headers.Headers
1288
+ ): Duration.Duration | undefined => {
1289
+ const retryAfter = parseRetryAfter(
1290
+ clock,
1291
+ getHeader(headers, "retry-after")
1292
+ )
1293
+ if (retryAfter !== undefined) {
1294
+ return retryAfter
1295
+ }
1296
+ const resetAfter = parseResetAfter(getHeader(headers, "ratelimit-reset-after", "x-ratelimit-reset-after"))
1297
+ if (resetAfter !== undefined) {
1298
+ return resetAfter
1299
+ }
1300
+ return parseResetHeader(clock, getHeader(headers, "ratelimit-reset", "x-ratelimit-reset"))
1301
+ }
1302
+
1303
+ const parseRetryAfter = (
1304
+ clock: Clock,
1305
+ value: string | undefined
1306
+ ): Duration.Duration | undefined => {
1307
+ if (value === undefined) {
1308
+ return undefined
1309
+ }
1310
+ const numeric = parseNumberHeader(value)
1311
+ if (numeric !== undefined) {
1312
+ return Duration.max(Duration.seconds(numeric), Duration.millis(1))
1313
+ }
1314
+ const parsedDate = Date.parse(value)
1315
+ if (Number.isNaN(parsedDate)) {
1316
+ return undefined
1317
+ }
1318
+ const millis = parsedDate - clock.currentTimeMillisUnsafe()
1319
+ if (millis <= 0) {
1320
+ return Duration.millis(1)
1321
+ }
1322
+ return Duration.millis(millis)
1323
+ }
1324
+
1325
+ const parseResetAfter = (value: string | undefined): Duration.Duration | undefined => {
1326
+ const numeric = parseNumberHeader(value)
1327
+ if (numeric === undefined || numeric <= 0) {
1328
+ return undefined
1329
+ }
1330
+ return Duration.max(Duration.seconds(numeric), Duration.millis(1))
1331
+ }
1332
+
1333
+ const parseResetHeader = (
1334
+ clock: Clock,
1335
+ value: string | undefined
1336
+ ): Duration.Duration | undefined => {
1337
+ const numeric = parseNumberHeader(value)
1338
+ if (numeric === undefined || numeric <= 0) {
1339
+ return undefined
1340
+ }
1341
+ const nowMillis = clock.currentTimeMillisUnsafe()
1342
+ if (numeric > 1_000_000_000_000) {
1343
+ return Duration.millis(Math.max(numeric - nowMillis, 1))
1344
+ }
1345
+ if (numeric > 1_000_000_000) {
1346
+ return Duration.millis(Math.max((numeric * 1_000) - nowMillis, 1))
1347
+ }
1348
+ return Duration.max(Duration.seconds(numeric), Duration.millis(1))
1349
+ }
1350
+
1351
+ const parseNumberHeader = (value: string | undefined): number | undefined => {
1352
+ if (value === undefined) {
1353
+ return undefined
1354
+ }
1355
+ const match = /-?\d+(?:\.\d+)?/.exec(value)
1356
+ if (match === null) {
1357
+ return undefined
1358
+ }
1359
+ const parsed = Number(match[0])
1360
+ return Number.isFinite(parsed) ? parsed : undefined
1361
+ }
1362
+
1363
+ const getHeader = (headers: Headers.Headers, ...keys: Array<string>): string | undefined => {
1364
+ for (let i = 0; i < keys.length; i++) {
1365
+ const value = headers[keys[i]]
1366
+ if (value !== undefined) {
1367
+ return value
1368
+ }
1369
+ }
1370
+ return undefined
1371
+ }
1372
+
1075
1373
  /**
1076
1374
  * Performs an additional effect after a successful request.
1077
1375
  *
@@ -1462,6 +1760,11 @@ const isTransientHttpError = (error: unknown) =>
1462
1760
  (error.reason._tag === "TransportError" ||
1463
1761
  (error.reason._tag === "StatusCodeError" && isTransientResponse(error.reason.response)))
1464
1762
 
1763
+ const isTooManyRequestsHttpClientError = (
1764
+ error: unknown
1765
+ ): error is Error.HttpClientError & { readonly reason: Error.StatusCodeError } =>
1766
+ Error.isHttpClientError(error) && error.reason._tag === "StatusCodeError" && error.reason.response.status === 429
1767
+
1465
1768
  const isTransientResponse = (response: HttpClientResponse.HttpClientResponse) =>
1466
1769
  response.status === 408 ||
1467
1770
  response.status === 429 ||
@@ -57,7 +57,7 @@ export interface HttpApi<
57
57
  /**
58
58
  * Prefix all endpoints in the `HttpApi`.
59
59
  */
60
- prefix<const Prefix extends PathInput>(prefix: Prefix): HttpApi<Id, Groups>
60
+ prefix<const Prefix extends PathInput>(prefix: Prefix): HttpApi<Id, HttpApiGroup.AddPrefix<Groups, Prefix>>
61
61
 
62
62
  /**
63
63
  * Add a middleware to a `HttpApi`. It will be applied to all endpoints in the
@@ -104,14 +104,14 @@ export const ordered = <Req extends Schema.Top, Res extends Schema.Top, _, E, R>
104
104
  >,
105
105
  SqlClient.TransactionConnection["Service"] | undefined
106
106
  >({
107
- key: (entry) => entry.services.mapUnsafe.get(SqlClient.TransactionConnection.key),
107
+ key: transactionKey,
108
108
  resolver: Effect.fnUntraced(function*(entries) {
109
109
  const inputs = yield* partitionRequests(entries, options.Request)
110
110
  const results = yield* options.execute(inputs as any).pipe(
111
111
  Effect.provideServices(entries[0].services)
112
112
  )
113
113
  if (results.length !== inputs.length) {
114
- return yield* Effect.fail(new ResultLengthMismatch({ expected: inputs.length, actual: results.length }))
114
+ return yield* new ResultLengthMismatch({ expected: inputs.length, actual: results.length })
115
115
  }
116
116
  const decodedResults = yield* decodeArray(results).pipe(
117
117
  Effect.provideServices(entries[0].services)
@@ -160,7 +160,7 @@ export const grouped = <Req extends Schema.Top, Res extends Schema.Top, K, Row,
160
160
  >,
161
161
  SqlClient.TransactionConnection["Service"] | undefined
162
162
  >({
163
- key: (entry) => entry.services.mapUnsafe.get(SqlClient.TransactionConnection.key),
163
+ key: transactionKey,
164
164
  resolver: Effect.fnUntraced(function*(entries) {
165
165
  const inputs = yield* partitionRequests(entries, options.Request)
166
166
  const resultMap = MutableHashMap.empty<K, Arr.NonEmptyArray<Res["Type"]>>()
@@ -226,7 +226,11 @@ export const findById = <Id extends Schema.Top, Res extends Schema.Top, Row, E,
226
226
  >,
227
227
  SqlClient.TransactionConnection["Service"] | undefined
228
228
  >({
229
- key: (entry) => entry.services.mapUnsafe.get(SqlClient.TransactionConnection.key),
229
+ key(entry) {
230
+ const conn = entry.services.mapUnsafe.get(SqlClient.TransactionConnection.key)
231
+ if (!conn) return undefined
232
+ return Equal.byReferenceUnsafe(conn)
233
+ },
230
234
  resolver: Effect.fnUntraced(function*(entries) {
231
235
  const [inputs, idMap] = yield* partitionRequestsById(entries, options.Id)
232
236
  const results = yield* options.execute(inputs as any).pipe(
@@ -279,7 +283,7 @@ const void_ = <Req extends Schema.Top, _, E, R>(
279
283
  >,
280
284
  SqlClient.TransactionConnection["Service"] | undefined
281
285
  >({
282
- key: (entry) => entry.services.mapUnsafe.get(SqlClient.TransactionConnection.key),
286
+ key: transactionKey,
283
287
  resolver: Effect.fnUntraced(function*(entries) {
284
288
  const inputs = yield* partitionRequests(entries, options.Request)
285
289
  yield* options.execute(inputs as any).pipe(
@@ -354,3 +358,9 @@ const partitionRequestsById = function*<In, A, E, R, InE>(
354
358
 
355
359
  return [inputs, byIdMap] as const
356
360
  }
361
+
362
+ function transactionKey<A>(entry: Request.Entry<A>): SqlClient.TransactionConnection["Service"] | undefined {
363
+ const conn = entry.services.mapUnsafe.get(SqlClient.TransactionConnection.key)
364
+ if (!conn) return undefined
365
+ return Equal.byReferenceUnsafe(conn)
366
+ }