@nyc-transit-kit/soda3 0.1.3 → 0.2.0

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/src/soql.ts CHANGED
@@ -3,351 +3,212 @@ import { PositiveInteger } from "@nyc-transit-kit/contracts/soda3"
3
3
  import * as Effect from "effect/Effect"
4
4
  import * as Schema from "effect/Schema"
5
5
  import { InvalidInputError } from "./errors"
6
-
6
+ import { decodeInput } from "./internal/decode"
7
7
  export const soqlSelectAll = "SELECT *"
8
-
9
8
  export type SoqlParameters = Readonly<Record<string, unknown>>
10
-
11
- export type SoqlFragment = {
12
- readonly text: string
13
- readonly parameters?: SoqlParameters
14
- }
15
-
16
- export type SocrataTimestampWindow = {
17
- readonly start: string
18
- readonly end: string
19
- }
20
-
21
- export type SocrataYearMonth = {
22
- readonly year: number
23
- readonly month: number
24
- }
25
-
26
- const soqlIdentifierPattern = /^[A-Za-z_][A-Za-z0-9_]*$/
27
- const soqlParameterNamePattern = /^[A-Za-z_][A-Za-z0-9_]*$/
28
- const isoDatePartsPattern = /^(\d{4})-(\d{2})-(\d{2})$/
29
-
30
- const SoqlIdentifier = Schema.String.pipe(
31
- Schema.check(
32
- Schema.isPattern(soqlIdentifierPattern, {
33
- message: "Expected a SoQL identifier such as route_id"
34
- })
35
- )
9
+ export type SoqlFragment = Readonly<{ text: string; parameters?: SoqlParameters }>
10
+ export type SocrataTimestampWindow = Readonly<{ start: string; end: string }>
11
+ export type SocrataYearMonth = Readonly<{ year: number; month: number }>
12
+ const fn = Effect.fn
13
+ const identifierPattern = /^[A-Za-z_][A-Za-z0-9_]*$/
14
+ const parameterPattern = /^[A-Za-z_][A-Za-z0-9_]*$/
15
+ const datePattern = /^(\d{4})-(\d{2})-(\d{2})$/
16
+ const patternString = (pattern: RegExp, message: string) =>
17
+ Schema.String.pipe(Schema.check(Schema.isPattern(pattern, { message })))
18
+ const SoqlIdentifier = patternString(
19
+ identifierPattern,
20
+ "Expected a SoQL identifier such as route_id"
36
21
  )
37
-
38
- const SoqlParameterName = Schema.String.pipe(
39
- Schema.check(
40
- Schema.isPattern(soqlParameterNamePattern, {
41
- message: "Expected a SoQL parameter name such as route"
42
- })
43
- )
22
+ const SoqlParameterName = patternString(
23
+ parameterPattern,
24
+ "Expected a SoQL parameter name such as route"
44
25
  )
45
-
46
26
  const SoqlSortDirection = Schema.Literals(["ASC", "DESC"])
47
27
  export type SoqlSortDirection = typeof SoqlSortDirection.Type
48
-
49
- const SocrataYear = Schema.Int.pipe(
50
- Schema.check(Schema.isGreaterThanOrEqualTo(1900)),
51
- Schema.check(Schema.isLessThanOrEqualTo(9999))
52
- )
53
-
54
- const SocrataMonth = Schema.Int.pipe(
55
- Schema.check(Schema.isGreaterThanOrEqualTo(1)),
56
- Schema.check(Schema.isLessThanOrEqualTo(12))
57
- )
58
-
59
- const SocrataYearMonthSchema = Schema.Struct({
60
- year: SocrataYear,
61
- month: SocrataMonth
62
- })
63
-
28
+ const intRange = (minimum: number, maximum: number) =>
29
+ Schema.Int.pipe(
30
+ Schema.check(Schema.isGreaterThanOrEqualTo(minimum)),
31
+ Schema.check(Schema.isLessThanOrEqualTo(maximum))
32
+ )
33
+ const SocrataYear = intRange(1900, 9999)
34
+ const SocrataMonth = intRange(1, 12)
35
+ const SocrataYearMonthSchema = Schema.Struct({ year: SocrataYear, month: SocrataMonth })
64
36
  const invalidQueryInput = (message: string) =>
65
- InvalidInputError.make({
66
- operation: "query",
67
- message
68
- })
69
-
70
- const decodeQueryInput =
71
- <S extends Schema.Top>(schema: S) =>
72
- (input: unknown) =>
73
- Schema.decodeUnknownEffect(schema)(input).pipe(
74
- Effect.catchTag("SchemaError", (error) => Effect.fail(invalidQueryInput(error.message)))
75
- )
76
-
77
- const decodeLimit = decodeQueryInput(PositiveInteger)
78
- const decodeIdentifier = decodeQueryInput(SoqlIdentifier)
79
- const decodeParameterName = decodeQueryInput(SoqlParameterName)
80
- const decodeSortDirection = decodeQueryInput(SoqlSortDirection)
81
- const decodeIsoDate = decodeQueryInput(IsoDate)
82
- const decodeYear = decodeQueryInput(SocrataYear)
83
- const decodeMonth = decodeQueryInput(SocrataMonth)
84
- const decodeYearMonth = decodeQueryInput(SocrataYearMonthSchema)
85
-
86
- const withParameters = (
87
- text: string,
88
- parameters: Readonly<Record<string, unknown>>
89
- ): SoqlFragment => ({
90
- text,
91
- parameters
92
- })
93
-
94
- const withoutParameters = (text: string): SoqlFragment => ({
95
- text
96
- })
97
-
98
- const mergeParameters = (fragments: ReadonlyArray<SoqlFragment>) =>
99
- Effect.gen(function* () {
100
- const parameters: Record<string, unknown> = {}
101
-
102
- for (const fragment of fragments) {
103
- for (const [key, value] of Object.entries(fragment.parameters ?? {})) {
104
- if (Object.hasOwn(parameters, key)) {
105
- return yield* invalidQueryInput(`Duplicate SoQL parameter name: ${key}`)
106
- }
107
- parameters[key] = value
37
+ InvalidInputError.make({ operation: "query", message })
38
+ const decode = <S extends Schema.Top>(schema: S) => decodeInput("query", schema)
39
+ const decodeLimit = decode(PositiveInteger)
40
+ const decodeIdentifier = decode(SoqlIdentifier)
41
+ const decodeParameterName = decode(SoqlParameterName)
42
+ const decodeSortDirection = decode(SoqlSortDirection)
43
+ const decodeIsoDate = decode(IsoDate)
44
+ const decodeYear = decode(SocrataYear)
45
+ const decodeMonth = decode(SocrataMonth)
46
+ const decodeYearMonth = decode(SocrataYearMonthSchema)
47
+ const fragment = (text: string, parameters?: SoqlParameters): SoqlFragment =>
48
+ parameters === undefined ? { text } : { text, parameters }
49
+ const mergeParameters = (fragments: ReadonlyArray<SoqlFragment>) => {
50
+ const parameters: Record<string, unknown> = {}
51
+ for (const part of fragments) {
52
+ for (const [key, value] of Object.entries(part.parameters ?? {})) {
53
+ if (Object.hasOwn(parameters, key)) {
54
+ return Effect.fail(invalidQueryInput(`Duplicate SoQL parameter name: ${key}`))
108
55
  }
56
+ parameters[key] = value
109
57
  }
110
-
111
- return Object.keys(parameters).length === 0 ? undefined : parameters
112
- })
113
-
114
- const pad2 = (value: number) => value.toString().padStart(2, "0")
115
-
58
+ }
59
+ return Effect.succeed(Object.keys(parameters).length === 0 ? undefined : parameters)
60
+ }
61
+ const dateMessage = "Expected an ISO calendar date in YYYY-MM-DD format"
62
+ const pad = (value: number, length: number) => value.toString().padStart(length, "0")
116
63
  const timestampFromParts = (year: number, month: number, day: number) =>
117
- `${year.toString().padStart(4, "0")}-${pad2(month)}-${pad2(day)}T00:00:00`
118
-
119
- const dateParts = (value: string) =>
120
- Effect.gen(function* () {
121
- const match = isoDatePartsPattern.exec(value)
122
- if (match === null) {
123
- return yield* invalidQueryInput("Expected an ISO calendar date in YYYY-MM-DD format")
124
- }
125
-
126
- const yearText = match[1]
127
- const monthText = match[2]
128
- const dayText = match[3]
129
- if (yearText === undefined || monthText === undefined || dayText === undefined) {
130
- return yield* invalidQueryInput("Expected an ISO calendar date in YYYY-MM-DD format")
131
- }
132
-
133
- return {
134
- year: Number(yearText),
135
- month: Number(monthText),
136
- day: Number(dayText)
137
- }
138
- })
139
-
140
- const utcTimestampFromDate = (date: Date) =>
141
- Effect.gen(function* () {
142
- if (Number.isNaN(date.getTime())) {
143
- return yield* invalidQueryInput("Expected a valid Date")
144
- }
145
-
146
- return timestampFromParts(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate())
147
- })
148
-
149
- const epochDay = (value: string) =>
150
- Effect.gen(function* () {
151
- const parts = yield* dateParts(value)
152
- return Date.UTC(parts.year, parts.month - 1, parts.day)
153
- })
154
-
155
- export const soqlLimit = Effect.fn("Soda3.soqlLimit")(function* (query: string, limit: unknown) {
156
- const decodedLimit = yield* decodeLimit(limit)
157
- return `${query} LIMIT ${decodedLimit}`
64
+ `${pad(year, 4)}-${pad(month, 2)}-${pad(day, 2)}T00:00:00`
65
+ const datePartsOf = (value: string) => {
66
+ const [, yearText, monthText, dayText] = datePattern.exec(value) ?? []
67
+ return yearText === undefined || monthText === undefined || dayText === undefined
68
+ ? Effect.fail(invalidQueryInput(dateMessage))
69
+ : Effect.succeed({ year: +yearText, month: +monthText, day: +dayText })
70
+ }
71
+ export const soqlLimit = fn("Soda3.soqlLimit")(function* (query: string, limit: unknown) {
72
+ return `${query} LIMIT ${yield* decodeLimit(limit)}`
158
73
  })
159
-
160
- export const soqlIdentifier = Effect.fn("Soda3.soqlIdentifier")(function* (input: unknown) {
74
+ export const soqlIdentifier = fn("Soda3.soqlIdentifier")(function* (input: unknown) {
161
75
  return yield* decodeIdentifier(input)
162
76
  })
163
-
164
- export const soqlParameterName = Effect.fn("Soda3.soqlParameterName")(function* (input: unknown) {
77
+ export const soqlParameterName = fn("Soda3.soqlParameterName")(function* (input: unknown) {
165
78
  return yield* decodeParameterName(input)
166
79
  })
167
-
168
- export const soqlParameter = Effect.fn("Soda3.soqlParameter")(function* (
169
- name: unknown,
170
- value: unknown
171
- ) {
172
- const decodedName = yield* decodeParameterName(name)
173
- return withParameters(`:${decodedName}`, {
174
- [decodedName]: value
175
- })
80
+ export const soqlParameter = fn("Soda3.soqlParameter")(function* (name: unknown, value: unknown) {
81
+ const key = yield* decodeParameterName(name)
82
+ return fragment(`:${key}`, { [key]: value })
176
83
  })
177
-
178
- export const soqlEq = Effect.fn("Soda3.soqlEq")(function* (
84
+ export const soqlEq = fn("Soda3.soqlEq")(function* (
179
85
  column: unknown,
180
86
  parameterName: unknown,
181
87
  value: unknown
182
88
  ) {
183
- const decodedColumn = yield* decodeIdentifier(column)
184
- const decodedParameterName = yield* decodeParameterName(parameterName)
185
-
186
- return withParameters(`${decodedColumn} = :${decodedParameterName}`, {
187
- [decodedParameterName]: value
188
- })
89
+ const col = yield* decodeIdentifier(column)
90
+ const key = yield* decodeParameterName(parameterName)
91
+ return fragment(`${col} = :${key}`, { [key]: value })
189
92
  })
190
-
191
- export const soqlIn = Effect.fn("Soda3.soqlIn")(function* (
93
+ export const soqlIn = fn("Soda3.soqlIn")(function* (
192
94
  column: unknown,
193
95
  parameterBaseName: unknown,
194
96
  values: ReadonlyArray<unknown>
195
97
  ) {
196
- const decodedColumn = yield* decodeIdentifier(column)
197
- const decodedParameterBaseName = yield* decodeParameterName(parameterBaseName)
198
-
199
98
  if (values.length === 0) {
200
99
  return yield* invalidQueryInput("SoQL IN predicates require at least one value")
201
100
  }
202
-
101
+ const col = yield* decodeIdentifier(column)
102
+ const base = yield* decodeParameterName(parameterBaseName)
203
103
  const parameters: Record<string, unknown> = {}
204
104
  const placeholders: Array<string> = []
205
-
206
105
  for (let index = 0; index < values.length; index += 1) {
207
- const parameterName = `${decodedParameterBaseName}_${index + 1}`
208
- parameters[parameterName] = values[index]
209
- placeholders.push(`:${parameterName}`)
106
+ const key = `${base}_${index + 1}`
107
+ parameters[key] = values[index]
108
+ placeholders.push(`:${key}`)
210
109
  }
211
-
212
- return withParameters(`${decodedColumn} IN (${placeholders.join(", ")})`, parameters)
110
+ return fragment(`${col} IN (${placeholders.join(", ")})`, parameters)
213
111
  })
214
-
215
- export const soqlIsNotNull = Effect.fn("Soda3.soqlIsNotNull")(function* (column: unknown) {
216
- const decodedColumn = yield* decodeIdentifier(column)
217
- return withoutParameters(`${decodedColumn} IS NOT NULL`)
112
+ export const soqlIsNotNull = fn("Soda3.soqlIsNotNull")(function* (column: unknown) {
113
+ return fragment(`${yield* decodeIdentifier(column)} IS NOT NULL`)
218
114
  })
219
-
220
- export const soqlAnd = Effect.fn("Soda3.soqlAnd")(function* (
221
- fragments: ReadonlyArray<SoqlFragment>
222
- ) {
115
+ export const soqlAnd = fn("Soda3.soqlAnd")(function* (fragments: ReadonlyArray<SoqlFragment>) {
223
116
  if (fragments.length === 0) {
224
117
  return yield* invalidQueryInput("SoQL AND composition requires at least one fragment")
225
118
  }
226
-
227
- const parameters = yield* mergeParameters(fragments)
228
- const text = fragments.map((fragment) => `(${fragment.text})`).join(" AND ")
229
-
230
- return parameters === undefined ? withoutParameters(text) : withParameters(text, parameters)
119
+ return fragment(
120
+ fragments.map((part) => `(${part.text})`).join(" AND "),
121
+ yield* mergeParameters(fragments)
122
+ )
231
123
  })
232
-
233
- export const soqlOrderBy = Effect.fn("Soda3.soqlOrderBy")(function* (
124
+ export const soqlOrderBy = fn("Soda3.soqlOrderBy")(function* (
234
125
  column: unknown,
235
126
  direction: unknown = "ASC"
236
127
  ) {
237
- const decodedColumn = yield* decodeIdentifier(column)
238
- const decodedDirection = yield* decodeSortDirection(direction)
239
- return withoutParameters(`ORDER BY ${decodedColumn} ${decodedDirection}`)
128
+ const col = yield* decodeIdentifier(column)
129
+ return fragment(`ORDER BY ${col} ${yield* decodeSortDirection(direction)}`)
240
130
  })
241
-
242
- export const socrataTimestamp = Effect.fn("Soda3.socrataTimestamp")(function* (
243
- date: string | Date
244
- ) {
131
+ export const socrataTimestamp = fn("Soda3.socrataTimestamp")(function* (date: string | Date) {
245
132
  if (date instanceof Date) {
246
- return yield* utcTimestampFromDate(date)
133
+ if (Number.isNaN(date.getTime())) {
134
+ return yield* invalidQueryInput("Expected a valid Date")
135
+ }
136
+ return timestampFromParts(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate())
247
137
  }
248
-
249
138
  const decodedDate = yield* decodeIsoDate(date)
250
- const parts = yield* dateParts(decodedDate)
139
+ const parts = yield* datePartsOf(decodedDate)
251
140
  return timestampFromParts(parts.year, parts.month, parts.day)
252
141
  })
253
-
254
- export const socrataMonthWindow = Effect.fn("Soda3.socrataMonthWindow")(function* (
142
+ export const socrataMonthWindow = fn("Soda3.socrataMonthWindow")(function* (
255
143
  year: unknown,
256
144
  month: unknown
257
145
  ) {
258
- const decodedYear = yield* decodeYear(year)
259
- const decodedMonth = yield* decodeMonth(month)
260
- const endYear = decodedMonth === 12 ? decodedYear + 1 : decodedYear
261
- const endMonth = decodedMonth === 12 ? 1 : decodedMonth + 1
262
-
146
+ const y = yield* decodeYear(year)
147
+ const m = yield* decodeMonth(month)
148
+ const endYear = m === 12 ? y + 1 : y
149
+ const endMonth = m === 12 ? 1 : m + 1
263
150
  return {
264
- start: timestampFromParts(decodedYear, decodedMonth, 1),
151
+ start: timestampFromParts(y, m, 1),
265
152
  end: timestampFromParts(endYear, endMonth, 1)
266
153
  }
267
154
  })
268
-
269
- export const socrataDateWindow = Effect.fn("Soda3.socrataDateWindow")(function* (
155
+ export const socrataDateWindow = fn("Soda3.socrataDateWindow")(function* (
270
156
  startDate: unknown,
271
157
  endDate: unknown
272
158
  ) {
273
- const decodedStartDate = yield* decodeIsoDate(startDate)
274
- const decodedEndDate = yield* decodeIsoDate(endDate)
275
- const startEpochDay = yield* epochDay(decodedStartDate)
276
- const endEpochDay = yield* epochDay(decodedEndDate)
277
-
278
- if (startEpochDay >= endEpochDay) {
159
+ const start = yield* datePartsOf(yield* decodeIsoDate(startDate))
160
+ const end = yield* datePartsOf(yield* decodeIsoDate(endDate))
161
+ if (
162
+ Date.UTC(start.year, start.month - 1, start.day) >= Date.UTC(end.year, end.month - 1, end.day)
163
+ ) {
279
164
  return yield* invalidQueryInput("Socrata date windows require startDate before endDate")
280
165
  }
281
-
282
166
  return {
283
- start: yield* socrataTimestamp(decodedStartDate),
284
- end: yield* socrataTimestamp(decodedEndDate)
167
+ start: timestampFromParts(start.year, start.month, start.day),
168
+ end: timestampFromParts(end.year, end.month, end.day)
285
169
  }
286
170
  })
287
-
288
- export const soqlTimestampRange = Effect.fn("Soda3.soqlTimestampRange")(function* (
171
+ export const soqlTimestampRange = fn("Soda3.soqlTimestampRange")(function* (
289
172
  column: unknown,
290
173
  startParameterName: unknown,
291
174
  endParameterName: unknown,
292
- window: SocrataTimestampWindow
175
+ w: SocrataTimestampWindow
293
176
  ) {
294
- const decodedColumn = yield* decodeIdentifier(column)
295
- const decodedStartParameterName = yield* decodeParameterName(startParameterName)
296
- const decodedEndParameterName = yield* decodeParameterName(endParameterName)
297
-
298
- if (decodedStartParameterName === decodedEndParameterName) {
299
- return yield* invalidQueryInput(`Duplicate SoQL parameter name: ${decodedStartParameterName}`)
177
+ const col = yield* decodeIdentifier(column)
178
+ const s = yield* decodeParameterName(startParameterName)
179
+ const e = yield* decodeParameterName(endParameterName)
180
+ if (s === e) {
181
+ return yield* invalidQueryInput(`Duplicate SoQL parameter name: ${s}`)
300
182
  }
301
-
302
- return withParameters(
303
- `${decodedColumn} >= :${decodedStartParameterName} AND ${decodedColumn} < :${decodedEndParameterName}`,
304
- {
305
- [decodedStartParameterName]: window.start,
306
- [decodedEndParameterName]: window.end
307
- }
308
- )
183
+ return fragment(`${col} >= :${s} AND ${col} < :${e}`, { [s]: w.start, [e]: w.end })
309
184
  })
310
-
311
- export const soqlMonthWindow = Effect.fn("Soda3.soqlMonthWindow")(function* (
185
+ export const soqlMonthWindow = fn("Soda3.soqlMonthWindow")(function* (
312
186
  column: unknown,
313
187
  year: unknown,
314
188
  month: unknown,
315
189
  parameterBaseName: unknown
316
190
  ) {
317
- const decodedParameterBaseName = yield* decodeParameterName(parameterBaseName)
191
+ const base = yield* decodeParameterName(parameterBaseName)
318
192
  const window = yield* socrataMonthWindow(year, month)
319
- return yield* soqlTimestampRange(
320
- column,
321
- `${decodedParameterBaseName}_start`,
322
- `${decodedParameterBaseName}_end`,
323
- window
324
- )
193
+ return yield* soqlTimestampRange(column, `${base}_start`, `${base}_end`, window)
325
194
  })
326
-
327
- export const soqlYearMonthRange = Effect.fn("Soda3.soqlYearMonthRange")(function* (
195
+ export const soqlYearMonthRange = fn("Soda3.soqlYearMonthRange")(function* (
328
196
  yearColumn: unknown,
329
197
  monthColumn: unknown,
330
198
  start: unknown,
331
199
  end: unknown
332
200
  ) {
333
- const decodedYearColumn = yield* decodeIdentifier(yearColumn)
334
- const decodedMonthColumn = yield* decodeIdentifier(monthColumn)
335
- const decodedStart = yield* decodeYearMonth(start)
336
- const decodedEnd = yield* decodeYearMonth(end)
337
- const startOrder = decodedStart.year * 12 + decodedStart.month
338
- const endOrder = decodedEnd.year * 12 + decodedEnd.month
339
-
201
+ const yearCol = yield* decodeIdentifier(yearColumn)
202
+ const monthCol = yield* decodeIdentifier(monthColumn)
203
+ const rs = yield* decodeYearMonth(start)
204
+ const re = yield* decodeYearMonth(end)
205
+ const startOrder = rs.year * 12 + rs.month
206
+ const endOrder = re.year * 12 + re.month
340
207
  if (startOrder > endOrder) {
341
208
  return yield* invalidQueryInput("SoQL year/month ranges require start before end")
342
209
  }
343
-
344
- return withParameters(
345
- `(${decodedYearColumn} > :start_year OR (${decodedYearColumn} = :start_year AND ${decodedMonthColumn} >= :start_month)) AND (${decodedYearColumn} < :end_year OR (${decodedYearColumn} = :end_year AND ${decodedMonthColumn} <= :end_month))`,
346
- {
347
- start_year: decodedStart.year,
348
- start_month: decodedStart.month,
349
- end_year: decodedEnd.year,
350
- end_month: decodedEnd.month
351
- }
210
+ return fragment(
211
+ `(${yearCol} > :start_year OR (${yearCol} = :start_year AND ${monthCol} >= :start_month)) AND (${yearCol} < :end_year OR (${yearCol} = :end_year AND ${monthCol} <= :end_month))`,
212
+ { start_year: rs.year, start_month: rs.month, end_year: re.year, end_month: re.month }
352
213
  )
353
214
  })
@@ -0,0 +1,145 @@
1
+ import * as Duration from "effect/Duration"
2
+ import * as Effect from "effect/Effect"
3
+ import * as Layer from "effect/Layer"
4
+ import * as Schedule from "effect/Schedule"
5
+ import * as Stream from "effect/Stream"
6
+ import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"
7
+ import type * as Headers from "effect/unstable/http/Headers"
8
+ import type * as HttpClientError from "effect/unstable/http/HttpClientError"
9
+ import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
10
+
11
+ export type FetchImplementation = (
12
+ input: string | URL | globalThis.Request,
13
+ init?: globalThis.RequestInit
14
+ ) => Promise<globalThis.Response>
15
+
16
+ export interface RequestPolicyConfig {
17
+ readonly retryTimes?: number
18
+ readonly retryDelayMs?: number
19
+ readonly timeoutMs?: number
20
+ }
21
+
22
+ export const httpStatusText = (status: number) => `HTTP ${status}`
23
+ export const retryableStatus = (status: number) => status === 429 || status >= 500
24
+ export const isOkStatus = (status: number) => status >= 200 && status < 300
25
+
26
+ const retryPolicy = <E>(
27
+ retryTimes: number,
28
+ retryDelayMs: number | undefined,
29
+ isRetryable: (error: E) => boolean
30
+ ) =>
31
+ retryDelayMs === undefined || retryDelayMs <= 0
32
+ ? {
33
+ times: retryTimes,
34
+ while: isRetryable
35
+ }
36
+ : {
37
+ schedule: Schedule.exponential(Duration.millis(retryDelayMs)).pipe(
38
+ Schedule.both(Schedule.recurs(retryTimes))
39
+ ),
40
+ while: isRetryable
41
+ }
42
+
43
+ export const withRequestPolicy = <A, E, R, Retryable extends E>(
44
+ config: RequestPolicyConfig,
45
+ effect: Effect.Effect<A, E, R>,
46
+ adapters: {
47
+ readonly isRetryable: (error: E) => error is Retryable
48
+ readonly onExhausted: (attempts: number, error: Retryable) => E
49
+ readonly onTimeout: (timeoutMs: number) => E
50
+ readonly retryableResult?: (value: A) => E | undefined
51
+ }
52
+ ): Effect.Effect<A, E, R> => {
53
+ const retryTimes = config.retryTimes ?? 0
54
+ const effectWithRetryableResults =
55
+ retryTimes > 0 && adapters.retryableResult !== undefined
56
+ ? effect.pipe(
57
+ Effect.flatMap((value) => {
58
+ const retryableError = adapters.retryableResult?.(value)
59
+ return retryableError === undefined
60
+ ? Effect.succeed(value)
61
+ : Effect.fail(retryableError)
62
+ })
63
+ )
64
+ : effect
65
+ const retried =
66
+ retryTimes > 0
67
+ ? effectWithRetryableResults.pipe(
68
+ Effect.retry(retryPolicy(retryTimes, config.retryDelayMs, adapters.isRetryable)),
69
+ Effect.catchIf(adapters.isRetryable, (error) =>
70
+ Effect.fail(adapters.onExhausted(retryTimes + 1, error))
71
+ )
72
+ )
73
+ : effectWithRetryableResults
74
+ const timeoutMs = config.timeoutMs
75
+
76
+ return timeoutMs === undefined
77
+ ? retried
78
+ : retried.pipe(
79
+ Effect.timeoutOrElse({
80
+ duration: timeoutMs,
81
+ orElse: () => Effect.fail(adapters.onTimeout(timeoutMs))
82
+ })
83
+ )
84
+ }
85
+
86
+ export const httpFailureInfo = (error: HttpClientError.HttpClientError) => {
87
+ const response = error.response
88
+
89
+ return response === undefined
90
+ ? {
91
+ status: 0,
92
+ statusText: error.message
93
+ }
94
+ : {
95
+ status: response.status,
96
+ statusText: httpStatusText(response.status)
97
+ }
98
+ }
99
+
100
+ export const toWebResponse = (response: HttpClientResponse.HttpClientResponse) =>
101
+ Effect.map(
102
+ Stream.toReadableStreamEffect(response.stream),
103
+ (body) =>
104
+ new Response(body, {
105
+ status: response.status,
106
+ headers: response.headers
107
+ })
108
+ )
109
+
110
+ export const contentLengthOf = (headers: Headers.Headers) => {
111
+ const value = headers["content-length"]
112
+ if (value === undefined) {
113
+ return undefined
114
+ }
115
+ const parsed = Number(value)
116
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined
117
+ }
118
+
119
+ const shouldRedactSearchParameter = (name: string) => {
120
+ const lower = name.toLowerCase()
121
+ return lower.includes("key") || lower.includes("token") || lower.includes("secret")
122
+ }
123
+
124
+ export const redactedUrl = (url: string) => {
125
+ try {
126
+ const parsed = new URL(url)
127
+
128
+ for (const name of parsed.searchParams.keys()) {
129
+ if (shouldRedactSearchParameter(name)) {
130
+ parsed.searchParams.set(name, "REDACTED")
131
+ }
132
+ }
133
+
134
+ return parsed.toString()
135
+ } catch {
136
+ return "[invalid-url]"
137
+ }
138
+ }
139
+
140
+ export const fetchHttpClientLayer = (fetchImplementation?: FetchImplementation) =>
141
+ fetchImplementation === undefined
142
+ ? FetchHttpClient.layer
143
+ : FetchHttpClient.layer.pipe(
144
+ Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchImplementation as typeof fetch))
145
+ )
@@ -1,21 +0,0 @@
1
- import type { Soda3ExportRequest, Soda3QueryRequest } from "@nyc-transit-kit/contracts/soda3"
2
-
3
- export const toQueryBody = (request: Soda3QueryRequest) => ({
4
- query: request.query,
5
- page: request.page,
6
- parameters: request.parameters,
7
- timeout: request.timeout,
8
- includeSystem: request.includeSystem,
9
- includeSynthetic: request.includeSynthetic,
10
- orderingSpecifier: request.orderingSpecifier
11
- })
12
-
13
- export const toExportBody = (request: Soda3ExportRequest) => ({
14
- query: request.query,
15
- parameters: request.parameters,
16
- timeout: request.timeout,
17
- serializationOptions: request.serializationOptions
18
- })
19
-
20
- export const rangeHeader = (request: Soda3ExportRequest) =>
21
- request.range === undefined ? undefined : `bytes=${request.range.start}-${request.range.end}`