@nyc-transit-kit/soda3 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nyc-transit-kit contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,5 @@
1
+ # @nyc-transit-kit/soda3
2
+
3
+ Part of `nyc-transit-kit`, an Effect-native Bun toolkit for official NYC and MTA transit data APIs.
4
+
5
+ See the repository README and docs/api-reference.md for public import paths, CLI commands, and release notes.
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@nyc-transit-kit/soda3",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "sideEffects": false,
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./catalog": "./src/catalog.ts",
9
+ "./client": "./src/client.ts",
10
+ "./endpoints": "./src/endpoints.ts",
11
+ "./errors": "./src/errors.ts",
12
+ "./export": "./src/export.ts",
13
+ "./query": "./src/query.ts",
14
+ "./soql": "./src/soql.ts"
15
+ },
16
+ "types": "./src/index.ts",
17
+ "files": [
18
+ "LICENSE",
19
+ "README.md",
20
+ "src"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsc -b",
24
+ "test": "bun test ./test"
25
+ },
26
+ "dependencies": {
27
+ "@nyc-transit-kit/contracts": "0.1.0",
28
+ "effect": "4.0.0-beta.83"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "license": "MIT",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/mannyc2/nyc-transit-kit.git",
37
+ "directory": "packages/soda3"
38
+ },
39
+ "homepage": "https://github.com/mannyc2/nyc-transit-kit#readme",
40
+ "bugs": {
41
+ "url": "https://github.com/mannyc2/nyc-transit-kit/issues"
42
+ },
43
+ "keywords": [
44
+ "bun",
45
+ "effect",
46
+ "mta",
47
+ "nyc",
48
+ "socrata",
49
+ "transit"
50
+ ],
51
+ "engines": {
52
+ "bun": ">=1.3.14"
53
+ }
54
+ }
package/src/catalog.ts ADDED
@@ -0,0 +1,28 @@
1
+ import type { Soda3CatalogSearchRequestInput } from "@nyc-transit-kit/contracts/soda3"
2
+ import * as Effect from "effect/Effect"
3
+ import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
4
+ import { buildCatalogSearchUrl } from "./endpoints"
5
+ import { decodeCatalogSearchResponse, decodeJson } from "./internal/responses"
6
+ import { Soda3ClientConfig } from "./internal/services"
7
+ import { addHeaders, executeRequest, requestWithPolicy } from "./internal/transport"
8
+
9
+ export { buildCatalogSearchUrl } from "./endpoints"
10
+ export type { Soda3CatalogSearchRequestInput }
11
+
12
+ export const catalogSearch = Effect.fn("Soda3.catalogSearch")(function* (
13
+ input: Soda3CatalogSearchRequestInput
14
+ ) {
15
+ const config = yield* Soda3ClientConfig
16
+ const url = yield* buildCatalogSearchUrl(input)
17
+ const request = HttpClientRequest.get(url, {
18
+ headers: addHeaders(config)
19
+ })
20
+
21
+ return yield* requestWithPolicy(
22
+ "catalog",
23
+ executeRequest("catalog", request).pipe(
24
+ Effect.flatMap((response) => decodeJson("catalog", response)),
25
+ Effect.flatMap(decodeCatalogSearchResponse)
26
+ )
27
+ )
28
+ })
package/src/client.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { catalogSearch } from "./catalog"
2
+ export { exportResponse } from "./export"
3
+ export { Soda3HttpLive } from "./internal/http"
4
+ export type { Soda3ClientConfigShape } from "./internal/services"
5
+ export { Soda3ClientConfig, Soda3Live } from "./internal/services"
6
+ export { queryRows } from "./query"
@@ -0,0 +1,98 @@
1
+ import {
2
+ Soda3CatalogSearchRequest,
3
+ type Soda3CatalogSearchRequestInput,
4
+ Soda3ExportRequest,
5
+ type Soda3ExportRequestInput,
6
+ Soda3QueryRequest,
7
+ type Soda3QueryRequestInput
8
+ } from "@nyc-transit-kit/contracts/soda3"
9
+ import * as Effect from "effect/Effect"
10
+ import * as Schema from "effect/Schema"
11
+ import { InvalidInputError } from "./errors"
12
+
13
+ export const socrataApiVersion = "v3"
14
+ export const defaultSocrataProtocol = "https"
15
+ export const discoveryApiHost = "api.us.socrata.com"
16
+
17
+ const decodeQueryRequest = (input: unknown) =>
18
+ Schema.decodeUnknownEffect(Soda3QueryRequest)(input).pipe(
19
+ Effect.catchTag("SchemaError", (error) =>
20
+ Effect.fail(
21
+ InvalidInputError.make({
22
+ operation: "query",
23
+ message: error.message
24
+ })
25
+ )
26
+ )
27
+ )
28
+
29
+ const decodeExportRequest = (input: unknown) =>
30
+ Schema.decodeUnknownEffect(Soda3ExportRequest)(input).pipe(
31
+ Effect.catchTag("SchemaError", (error) =>
32
+ Effect.fail(
33
+ InvalidInputError.make({
34
+ operation: "export",
35
+ message: error.message
36
+ })
37
+ )
38
+ )
39
+ )
40
+
41
+ const decodeCatalogSearchRequest = (input: unknown) =>
42
+ Schema.decodeUnknownEffect(Soda3CatalogSearchRequest)(input).pipe(
43
+ Effect.catchTag("SchemaError", (error) =>
44
+ Effect.fail(
45
+ InvalidInputError.make({
46
+ operation: "catalog",
47
+ message: error.message
48
+ })
49
+ )
50
+ )
51
+ )
52
+
53
+ const viewUrl = (domain: string, datasetId: string, suffix: string) => {
54
+ const url = new URL(`${defaultSocrataProtocol}://${domain}`)
55
+ url.pathname = `/api/${socrataApiVersion}/views/${datasetId}/${suffix}`
56
+ return url
57
+ }
58
+
59
+ export const buildQueryUrl = Effect.fn("Soda3.buildQueryUrl")(function* (
60
+ input: Soda3QueryRequestInput
61
+ ) {
62
+ const request = yield* decodeQueryRequest(input)
63
+ return viewUrl(request.domain, request.datasetId, "query.json")
64
+ })
65
+
66
+ export const buildExportUrl = Effect.fn("Soda3.buildExportUrl")(function* (
67
+ input: Soda3ExportRequestInput
68
+ ) {
69
+ const request = yield* decodeExportRequest(input)
70
+ return viewUrl(request.domain, request.datasetId, `export.${request.format}`)
71
+ })
72
+
73
+ export const buildCatalogSearchUrl = Effect.fn("Soda3.buildCatalogSearchUrl")(function* (
74
+ input: Soda3CatalogSearchRequestInput
75
+ ) {
76
+ const request = yield* decodeCatalogSearchRequest(input)
77
+ const url = new URL(`${defaultSocrataProtocol}://${discoveryApiHost}`)
78
+ url.pathname = "/api/catalog/v1"
79
+ url.searchParams.set("domains", request.domain)
80
+ url.searchParams.set("search_context", request.domain)
81
+ url.searchParams.set("only", "dataset")
82
+
83
+ if (request.query !== undefined) {
84
+ url.searchParams.set("search", request.query)
85
+ }
86
+ if (request.limit !== undefined) {
87
+ url.searchParams.set("limit", request.limit.toString())
88
+ }
89
+ if (request.offset !== undefined) {
90
+ url.searchParams.set("offset", request.offset.toString())
91
+ }
92
+
93
+ return url
94
+ })
95
+
96
+ export const decodeSoda3QueryRequest = decodeQueryRequest
97
+ export const decodeSoda3ExportRequest = decodeExportRequest
98
+ export const decodeSoda3CatalogSearchRequest = decodeCatalogSearchRequest
package/src/errors.ts ADDED
@@ -0,0 +1,51 @@
1
+ import * as Schema from "effect/Schema"
2
+
3
+ export class InvalidInputError extends Schema.TaggedErrorClass<InvalidInputError>()(
4
+ "InvalidInputError",
5
+ {
6
+ operation: Schema.String,
7
+ message: Schema.String
8
+ }
9
+ ) {}
10
+
11
+ export class ProviderHttpError extends Schema.TaggedErrorClass<ProviderHttpError>()(
12
+ "ProviderHttpError",
13
+ {
14
+ operation: Schema.String,
15
+ status: Schema.Number,
16
+ statusText: Schema.String,
17
+ retryable: Schema.Boolean
18
+ }
19
+ ) {}
20
+
21
+ export class ProviderContractError extends Schema.TaggedErrorClass<ProviderContractError>()(
22
+ "ProviderContractError",
23
+ {
24
+ operation: Schema.String,
25
+ message: Schema.String
26
+ }
27
+ ) {}
28
+
29
+ export class TimeoutError extends Schema.TaggedErrorClass<TimeoutError>()("TimeoutError", {
30
+ operation: Schema.String,
31
+ timeoutMs: Schema.Number
32
+ }) {}
33
+
34
+ export class RetryExhaustedError extends Schema.TaggedErrorClass<RetryExhaustedError>()(
35
+ "RetryExhaustedError",
36
+ {
37
+ operation: Schema.String,
38
+ attempts: Schema.Number,
39
+ message: Schema.String
40
+ }
41
+ ) {}
42
+
43
+ export type Soda3ClientError =
44
+ | InvalidInputError
45
+ | ProviderHttpError
46
+ | ProviderContractError
47
+ | TimeoutError
48
+ | RetryExhaustedError
49
+
50
+ export const isRetryableProviderError = (error: Soda3ClientError): error is ProviderHttpError =>
51
+ error._tag === "ProviderHttpError" && error.retryable
package/src/export.ts ADDED
@@ -0,0 +1,41 @@
1
+ import type { Soda3ExportRequestInput } from "@nyc-transit-kit/contracts/soda3"
2
+ import * as Effect from "effect/Effect"
3
+ import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
4
+ import { buildExportUrl, decodeSoda3ExportRequest } from "./endpoints"
5
+ import { InvalidInputError } from "./errors"
6
+ import { rangeHeader, toExportBody } from "./internal/bodies"
7
+ import { toWebResponse } from "./internal/http"
8
+ import { Soda3ClientConfig } from "./internal/services"
9
+ import { addHeaders, executeRequest, requestWithPolicy } from "./internal/transport"
10
+
11
+ export type { Soda3ExportRequestInput }
12
+
13
+ export const exportResponse = Effect.fn("Soda3.exportResponse")(function* (
14
+ input: Soda3ExportRequestInput
15
+ ) {
16
+ const config = yield* Soda3ClientConfig
17
+ const exportRequest = yield* decodeSoda3ExportRequest(input)
18
+ const url = yield* buildExportUrl(exportRequest)
19
+ const headers = addHeaders(config)
20
+ const range = rangeHeader(exportRequest)
21
+
22
+ const request = yield* HttpClientRequest.post(url, {
23
+ headers: {
24
+ ...headers,
25
+ range
26
+ }
27
+ }).pipe(
28
+ HttpClientRequest.bodyJson(toExportBody(exportRequest)),
29
+ Effect.mapError(() =>
30
+ InvalidInputError.make({
31
+ operation: "export",
32
+ message: "SODA3 export body was not JSON serializable"
33
+ })
34
+ )
35
+ )
36
+
37
+ return yield* requestWithPolicy(
38
+ "export",
39
+ executeRequest("export", request).pipe(Effect.flatMap(toWebResponse))
40
+ )
41
+ })
package/src/index.ts ADDED
@@ -0,0 +1,50 @@
1
+ export const packageName = "@nyc-transit-kit/soda3"
2
+ export { buildCatalogSearchUrl, catalogSearch } from "./catalog"
3
+ export type { Soda3ClientConfigShape } from "./client"
4
+ export { Soda3ClientConfig, Soda3HttpLive, Soda3Live } from "./client"
5
+ export {
6
+ buildExportUrl,
7
+ buildQueryUrl,
8
+ decodeSoda3CatalogSearchRequest,
9
+ decodeSoda3ExportRequest,
10
+ decodeSoda3QueryRequest,
11
+ defaultSocrataProtocol,
12
+ discoveryApiHost,
13
+ socrataApiVersion
14
+ } from "./endpoints"
15
+ export type { Soda3ClientError } from "./errors"
16
+ export {
17
+ InvalidInputError,
18
+ isRetryableProviderError,
19
+ ProviderContractError,
20
+ ProviderHttpError,
21
+ RetryExhaustedError,
22
+ TimeoutError
23
+ } from "./errors"
24
+ export { exportResponse } from "./export"
25
+ export { queryRows } from "./query"
26
+ export type {
27
+ SocrataTimestampWindow,
28
+ SocrataYearMonth,
29
+ SoqlFragment,
30
+ SoqlParameters,
31
+ SoqlSortDirection
32
+ } from "./soql"
33
+ export {
34
+ socrataDateWindow,
35
+ socrataMonthWindow,
36
+ socrataTimestamp,
37
+ soqlAnd,
38
+ soqlEq,
39
+ soqlIdentifier,
40
+ soqlIn,
41
+ soqlIsNotNull,
42
+ soqlLimit,
43
+ soqlMonthWindow,
44
+ soqlOrderBy,
45
+ soqlParameter,
46
+ soqlParameterName,
47
+ soqlSelectAll,
48
+ soqlTimestampRange,
49
+ soqlYearMonthRange
50
+ } from "./soql"
@@ -0,0 +1,21 @@
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}`
@@ -0,0 +1,42 @@
1
+ import * as Effect from "effect/Effect"
2
+ import * as Stream from "effect/Stream"
3
+ import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"
4
+ import type * as HttpClientError from "effect/unstable/http/HttpClientError"
5
+ import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
6
+ import { ProviderHttpError } from "../errors"
7
+
8
+ export const Soda3HttpLive = FetchHttpClient.layer
9
+
10
+ export const retryableStatus = (status: number) => status === 429 || status >= 500
11
+
12
+ const httpStatusText = (status: number) => `HTTP ${status}`
13
+
14
+ export const toWebResponse = (response: HttpClientResponse.HttpClientResponse) =>
15
+ Effect.map(
16
+ Stream.toReadableStreamEffect(response.stream),
17
+ (body) =>
18
+ new Response(body, {
19
+ status: response.status,
20
+ headers: response.headers
21
+ })
22
+ )
23
+
24
+ export const mapHttpClientError = (operation: string, error: HttpClientError.HttpClientError) => {
25
+ const response = error.response
26
+
27
+ if (response !== undefined) {
28
+ return ProviderHttpError.make({
29
+ operation,
30
+ status: response.status,
31
+ statusText: httpStatusText(response.status),
32
+ retryable: retryableStatus(response.status)
33
+ })
34
+ }
35
+
36
+ return ProviderHttpError.make({
37
+ operation,
38
+ status: 0,
39
+ statusText: error.message,
40
+ retryable: true
41
+ })
42
+ }
@@ -0,0 +1,56 @@
1
+ import {
2
+ Soda3CatalogSearchResponse,
3
+ Soda3QueryResponse,
4
+ Soda3Row
5
+ } from "@nyc-transit-kit/contracts/soda3"
6
+ import * as Effect from "effect/Effect"
7
+ import * as Schema from "effect/Schema"
8
+ import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
9
+ import { ProviderContractError } from "../errors"
10
+
11
+ const providerRows = Schema.Array(Soda3Row)
12
+
13
+ export const decodeJson = (operation: string, response: HttpClientResponse.HttpClientResponse) =>
14
+ response.json.pipe(
15
+ Effect.catchTag("HttpClientError", () =>
16
+ Effect.fail(
17
+ ProviderContractError.make({
18
+ operation,
19
+ message: "Provider response was not valid JSON"
20
+ })
21
+ )
22
+ )
23
+ )
24
+
25
+ export const decodeQueryResponse = (input: unknown) =>
26
+ Schema.decodeUnknownEffect(Soda3QueryResponse)(input).pipe(
27
+ Effect.catchTag("SchemaError", (error) =>
28
+ Schema.decodeUnknownEffect(providerRows)(input).pipe(
29
+ Effect.map((rows) =>
30
+ Soda3QueryResponse.make({
31
+ rows
32
+ })
33
+ ),
34
+ Effect.catchTag("SchemaError", () =>
35
+ Effect.fail(
36
+ ProviderContractError.make({
37
+ operation: "query",
38
+ message: error.message
39
+ })
40
+ )
41
+ )
42
+ )
43
+ )
44
+ )
45
+
46
+ export const decodeCatalogSearchResponse = (input: unknown) =>
47
+ Schema.decodeUnknownEffect(Soda3CatalogSearchResponse)(input).pipe(
48
+ Effect.catchTag("SchemaError", (error) =>
49
+ Effect.fail(
50
+ ProviderContractError.make({
51
+ operation: "catalog",
52
+ message: error.message
53
+ })
54
+ )
55
+ )
56
+ )
@@ -0,0 +1,19 @@
1
+ import * as Context from "effect/Context"
2
+ import * as Layer from "effect/Layer"
3
+ import { Soda3HttpLive } from "./http"
4
+
5
+ export interface Soda3ClientConfigShape {
6
+ readonly appToken?: string
7
+ readonly retryTimes?: number
8
+ readonly timeoutMs?: number
9
+ }
10
+
11
+ export class Soda3ClientConfig extends Context.Service<Soda3ClientConfig, Soda3ClientConfigShape>()(
12
+ "@nyc-transit-kit/soda3/Soda3ClientConfig"
13
+ ) {
14
+ static readonly Default = Layer.succeed(Soda3ClientConfig)({
15
+ retryTimes: 0
16
+ })
17
+ }
18
+
19
+ export const Soda3Live = Layer.mergeAll(Soda3ClientConfig.Default, Soda3HttpLive)
@@ -0,0 +1,87 @@
1
+ import * as Effect from "effect/Effect"
2
+ import * as HttpClient from "effect/unstable/http/HttpClient"
3
+ import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
4
+ import type { Soda3ClientError } from "../errors"
5
+ import {
6
+ isRetryableProviderError,
7
+ ProviderHttpError,
8
+ RetryExhaustedError,
9
+ TimeoutError
10
+ } from "../errors"
11
+ import { mapHttpClientError, retryableStatus } from "./http"
12
+ import { Soda3ClientConfig, type Soda3ClientConfigShape } from "./services"
13
+
14
+ export const addHeaders = (config: Soda3ClientConfigShape) => ({
15
+ accept: "application/json",
16
+ ...(config.appToken !== undefined && config.appToken.length > 0
17
+ ? { "X-App-Token": config.appToken }
18
+ : {})
19
+ })
20
+
21
+ export { retryableStatus }
22
+
23
+ export const requestWithPolicy = <A>(
24
+ operation: string,
25
+ effect: Effect.Effect<A, Soda3ClientError, HttpClient.HttpClient | Soda3ClientConfig>
26
+ ) =>
27
+ Effect.gen(function* () {
28
+ const config = yield* Soda3ClientConfig
29
+ const retryTimes = config.retryTimes ?? 0
30
+ const retried =
31
+ retryTimes > 0
32
+ ? effect.pipe(
33
+ Effect.retry({
34
+ times: retryTimes,
35
+ while: isRetryableProviderError
36
+ }),
37
+ Effect.catchIf(isRetryableProviderError, (error) =>
38
+ Effect.fail(
39
+ RetryExhaustedError.make({
40
+ operation,
41
+ attempts: retryTimes + 1,
42
+ message: `Retry attempts exhausted after provider status ${error.status}`
43
+ })
44
+ )
45
+ )
46
+ )
47
+ : effect
48
+
49
+ const timeoutMs = config.timeoutMs
50
+ const timed =
51
+ timeoutMs === undefined
52
+ ? retried
53
+ : retried.pipe(
54
+ Effect.timeoutOrElse({
55
+ duration: timeoutMs,
56
+ orElse: () =>
57
+ Effect.fail(
58
+ TimeoutError.make({
59
+ operation,
60
+ timeoutMs
61
+ })
62
+ )
63
+ })
64
+ )
65
+
66
+ return yield* timed
67
+ })
68
+
69
+ const httpStatusText = (status: number) => `HTTP ${status}`
70
+
71
+ export const executeRequest = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
72
+ Effect.gen(function* () {
73
+ const response = yield* HttpClient.execute(request).pipe(
74
+ Effect.mapError((error) => mapHttpClientError(operation, error))
75
+ )
76
+
77
+ if (response.status < 200 || response.status >= 300) {
78
+ return yield* ProviderHttpError.make({
79
+ operation,
80
+ status: response.status,
81
+ statusText: httpStatusText(response.status),
82
+ retryable: retryableStatus(response.status)
83
+ })
84
+ }
85
+
86
+ return response
87
+ })
package/src/query.ts ADDED
@@ -0,0 +1,36 @@
1
+ import type { Soda3QueryRequestInput } from "@nyc-transit-kit/contracts/soda3"
2
+ import * as Effect from "effect/Effect"
3
+ import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
4
+ import { buildQueryUrl, decodeSoda3QueryRequest } from "./endpoints"
5
+ import { InvalidInputError } from "./errors"
6
+ import { toQueryBody } from "./internal/bodies"
7
+ import { decodeJson, decodeQueryResponse } from "./internal/responses"
8
+ import { Soda3ClientConfig } from "./internal/services"
9
+ import { addHeaders, executeRequest, requestWithPolicy } from "./internal/transport"
10
+
11
+ export type { Soda3QueryRequestInput }
12
+
13
+ export const queryRows = Effect.fn("Soda3.queryRows")(function* (input: Soda3QueryRequestInput) {
14
+ const config = yield* Soda3ClientConfig
15
+ const queryRequest = yield* decodeSoda3QueryRequest(input)
16
+ const url = yield* buildQueryUrl(queryRequest)
17
+ const request = yield* HttpClientRequest.post(url, {
18
+ headers: addHeaders(config)
19
+ }).pipe(
20
+ HttpClientRequest.bodyJson(toQueryBody(queryRequest)),
21
+ Effect.mapError(() =>
22
+ InvalidInputError.make({
23
+ operation: "query",
24
+ message: "SODA3 query body was not JSON serializable"
25
+ })
26
+ )
27
+ )
28
+
29
+ return yield* requestWithPolicy(
30
+ "query",
31
+ executeRequest("query", request).pipe(
32
+ Effect.flatMap((response) => decodeJson("query", response)),
33
+ Effect.flatMap(decodeQueryResponse)
34
+ )
35
+ )
36
+ })
package/src/soql.ts ADDED
@@ -0,0 +1,353 @@
1
+ import { IsoDate } from "@nyc-transit-kit/contracts/ids"
2
+ import { PositiveInteger } from "@nyc-transit-kit/contracts/soda3"
3
+ import * as Effect from "effect/Effect"
4
+ import * as Schema from "effect/Schema"
5
+ import { InvalidInputError } from "./errors"
6
+
7
+ export const soqlSelectAll = "SELECT *"
8
+
9
+ 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
+ )
36
+ )
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
+ )
44
+ )
45
+
46
+ const SoqlSortDirection = Schema.Literals(["ASC", "DESC"])
47
+ 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
+
64
+ 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
108
+ }
109
+ }
110
+
111
+ return Object.keys(parameters).length === 0 ? undefined : parameters
112
+ })
113
+
114
+ const pad2 = (value: number) => value.toString().padStart(2, "0")
115
+
116
+ 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}`
158
+ })
159
+
160
+ export const soqlIdentifier = Effect.fn("Soda3.soqlIdentifier")(function* (input: unknown) {
161
+ return yield* decodeIdentifier(input)
162
+ })
163
+
164
+ export const soqlParameterName = Effect.fn("Soda3.soqlParameterName")(function* (input: unknown) {
165
+ return yield* decodeParameterName(input)
166
+ })
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
+ })
176
+ })
177
+
178
+ export const soqlEq = Effect.fn("Soda3.soqlEq")(function* (
179
+ column: unknown,
180
+ parameterName: unknown,
181
+ value: unknown
182
+ ) {
183
+ const decodedColumn = yield* decodeIdentifier(column)
184
+ const decodedParameterName = yield* decodeParameterName(parameterName)
185
+
186
+ return withParameters(`${decodedColumn} = :${decodedParameterName}`, {
187
+ [decodedParameterName]: value
188
+ })
189
+ })
190
+
191
+ export const soqlIn = Effect.fn("Soda3.soqlIn")(function* (
192
+ column: unknown,
193
+ parameterBaseName: unknown,
194
+ values: ReadonlyArray<unknown>
195
+ ) {
196
+ const decodedColumn = yield* decodeIdentifier(column)
197
+ const decodedParameterBaseName = yield* decodeParameterName(parameterBaseName)
198
+
199
+ if (values.length === 0) {
200
+ return yield* invalidQueryInput("SoQL IN predicates require at least one value")
201
+ }
202
+
203
+ const parameters: Record<string, unknown> = {}
204
+ const placeholders: Array<string> = []
205
+
206
+ for (let index = 0; index < values.length; index += 1) {
207
+ const parameterName = `${decodedParameterBaseName}_${index + 1}`
208
+ parameters[parameterName] = values[index]
209
+ placeholders.push(`:${parameterName}`)
210
+ }
211
+
212
+ return withParameters(`${decodedColumn} IN (${placeholders.join(", ")})`, parameters)
213
+ })
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`)
218
+ })
219
+
220
+ export const soqlAnd = Effect.fn("Soda3.soqlAnd")(function* (
221
+ fragments: ReadonlyArray<SoqlFragment>
222
+ ) {
223
+ if (fragments.length === 0) {
224
+ return yield* invalidQueryInput("SoQL AND composition requires at least one fragment")
225
+ }
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)
231
+ })
232
+
233
+ export const soqlOrderBy = Effect.fn("Soda3.soqlOrderBy")(function* (
234
+ column: unknown,
235
+ direction: unknown = "ASC"
236
+ ) {
237
+ const decodedColumn = yield* decodeIdentifier(column)
238
+ const decodedDirection = yield* decodeSortDirection(direction)
239
+ return withoutParameters(`ORDER BY ${decodedColumn} ${decodedDirection}`)
240
+ })
241
+
242
+ export const socrataTimestamp = Effect.fn("Soda3.socrataTimestamp")(function* (
243
+ date: string | Date
244
+ ) {
245
+ if (date instanceof Date) {
246
+ return yield* utcTimestampFromDate(date)
247
+ }
248
+
249
+ const decodedDate = yield* decodeIsoDate(date)
250
+ const parts = yield* dateParts(decodedDate)
251
+ return timestampFromParts(parts.year, parts.month, parts.day)
252
+ })
253
+
254
+ export const socrataMonthWindow = Effect.fn("Soda3.socrataMonthWindow")(function* (
255
+ year: unknown,
256
+ month: unknown
257
+ ) {
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
+
263
+ return {
264
+ start: timestampFromParts(decodedYear, decodedMonth, 1),
265
+ end: timestampFromParts(endYear, endMonth, 1)
266
+ }
267
+ })
268
+
269
+ export const socrataDateWindow = Effect.fn("Soda3.socrataDateWindow")(function* (
270
+ startDate: unknown,
271
+ endDate: unknown
272
+ ) {
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) {
279
+ return yield* invalidQueryInput("Socrata date windows require startDate before endDate")
280
+ }
281
+
282
+ return {
283
+ start: yield* socrataTimestamp(decodedStartDate),
284
+ end: yield* socrataTimestamp(decodedEndDate)
285
+ }
286
+ })
287
+
288
+ export const soqlTimestampRange = Effect.fn("Soda3.soqlTimestampRange")(function* (
289
+ column: unknown,
290
+ startParameterName: unknown,
291
+ endParameterName: unknown,
292
+ window: SocrataTimestampWindow
293
+ ) {
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}`)
300
+ }
301
+
302
+ return withParameters(
303
+ `${decodedColumn} >= :${decodedStartParameterName} AND ${decodedColumn} < :${decodedEndParameterName}`,
304
+ {
305
+ [decodedStartParameterName]: window.start,
306
+ [decodedEndParameterName]: window.end
307
+ }
308
+ )
309
+ })
310
+
311
+ export const soqlMonthWindow = Effect.fn("Soda3.soqlMonthWindow")(function* (
312
+ column: unknown,
313
+ year: unknown,
314
+ month: unknown,
315
+ parameterBaseName: unknown
316
+ ) {
317
+ const decodedParameterBaseName = yield* decodeParameterName(parameterBaseName)
318
+ const window = yield* socrataMonthWindow(year, month)
319
+ return yield* soqlTimestampRange(
320
+ column,
321
+ `${decodedParameterBaseName}_start`,
322
+ `${decodedParameterBaseName}_end`,
323
+ window
324
+ )
325
+ })
326
+
327
+ export const soqlYearMonthRange = Effect.fn("Soda3.soqlYearMonthRange")(function* (
328
+ yearColumn: unknown,
329
+ monthColumn: unknown,
330
+ start: unknown,
331
+ end: unknown
332
+ ) {
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
+
340
+ if (startOrder > endOrder) {
341
+ return yield* invalidQueryInput("SoQL year/month ranges require start before end")
342
+ }
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
+ }
352
+ )
353
+ })