@nyc-transit-kit/soda3 0.1.3 → 0.1.4
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/README.md +27 -2
- package/package.json +4 -3
- package/src/client.ts +7 -2
- package/src/endpoints.ts +12 -0
- package/src/index.ts +5 -2
- package/src/internal/bodies.ts +3 -1
- package/src/internal/services.ts +10 -3
- package/src/internal/transport.ts +16 -4
- package/src/metadata.ts +47 -0
- package/src/query.ts +98 -2
package/README.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# @nyc-transit-kit/soda3
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Generic Effect-native client for Socrata SODA3 query, export, and Discovery
|
|
4
|
+
catalog APIs.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
Socrata-backed data access is SODA3-only. This package does not expose legacy
|
|
7
|
+
Socrata endpoint helpers, fallback endpoint builders, or compatibility aliases.
|
|
8
|
+
|
|
9
|
+
## Import Paths
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { soda3Layer } from "@nyc-transit-kit/soda3/client"
|
|
13
|
+
import { queryRows } from "@nyc-transit-kit/soda3/query"
|
|
14
|
+
import * as Effect from "effect/Effect"
|
|
15
|
+
|
|
16
|
+
const program = queryRows({
|
|
17
|
+
domain: "data.cityofnewyork.us",
|
|
18
|
+
datasetId: "ycrg-ses3",
|
|
19
|
+
query: "SELECT * LIMIT 5"
|
|
20
|
+
}).pipe(Effect.provide(soda3Layer({ retryTimes: 2 })))
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Prefer subpaths such as `@nyc-transit-kit/soda3/query`,
|
|
24
|
+
`@nyc-transit-kit/soda3/export`, `@nyc-transit-kit/soda3/catalog`, and
|
|
25
|
+
`@nyc-transit-kit/soda3/client`.
|
|
26
|
+
|
|
27
|
+
## Compatibility
|
|
28
|
+
|
|
29
|
+
Each `nyc-transit-kit` release pins one exact Effect 4 beta. Match the Effect
|
|
30
|
+
version documented in `docs/getting-started.md`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nyc-transit-kit/soda3",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"license": "MIT",
|
|
4
5
|
"type": "module",
|
|
5
6
|
"sideEffects": false,
|
|
6
7
|
"exports": {
|
|
@@ -10,6 +11,7 @@
|
|
|
10
11
|
"./endpoints": "./src/endpoints.ts",
|
|
11
12
|
"./errors": "./src/errors.ts",
|
|
12
13
|
"./export": "./src/export.ts",
|
|
14
|
+
"./metadata": "./src/metadata.ts",
|
|
13
15
|
"./query": "./src/query.ts",
|
|
14
16
|
"./soql": "./src/soql.ts"
|
|
15
17
|
},
|
|
@@ -24,13 +26,12 @@
|
|
|
24
26
|
"test": "bun test ./test"
|
|
25
27
|
},
|
|
26
28
|
"dependencies": {
|
|
27
|
-
"@nyc-transit-kit/contracts": "0.1.
|
|
29
|
+
"@nyc-transit-kit/contracts": "0.1.4",
|
|
28
30
|
"effect": "4.0.0-beta.92"
|
|
29
31
|
},
|
|
30
32
|
"publishConfig": {
|
|
31
33
|
"access": "public"
|
|
32
34
|
},
|
|
33
|
-
"license": "MIT",
|
|
34
35
|
"repository": {
|
|
35
36
|
"type": "git",
|
|
36
37
|
"url": "git+https://github.com/mannyc2/nyc-transit-kit.git",
|
package/src/client.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
export { catalogSearch } from "./catalog"
|
|
2
2
|
export { exportResponse } from "./export"
|
|
3
3
|
export { Soda3HttpLive } from "./internal/http"
|
|
4
|
-
export type {
|
|
4
|
+
export type {
|
|
5
|
+
FetchImplementation,
|
|
6
|
+
Soda3ClientConfigShape,
|
|
7
|
+
Soda3LayerOptions
|
|
8
|
+
} from "./internal/services"
|
|
5
9
|
export {
|
|
6
10
|
Soda3ClientConfig,
|
|
7
11
|
Soda3Live,
|
|
@@ -9,4 +13,5 @@ export {
|
|
|
9
13
|
soda3FetchLayer,
|
|
10
14
|
soda3Layer
|
|
11
15
|
} from "./internal/services"
|
|
12
|
-
export {
|
|
16
|
+
export { fetchDatasetMetadata } from "./metadata"
|
|
17
|
+
export { countRows, queryAllRows, queryRows } from "./query"
|
package/src/endpoints.ts
CHANGED
|
@@ -83,6 +83,18 @@ export const buildCatalogSearchUrl = Effect.fn("Soda3.buildCatalogSearchUrl")(fu
|
|
|
83
83
|
if (request.query !== undefined) {
|
|
84
84
|
url.searchParams.set("search", request.query)
|
|
85
85
|
}
|
|
86
|
+
for (const id of request.ids ?? []) {
|
|
87
|
+
url.searchParams.append("ids", id)
|
|
88
|
+
}
|
|
89
|
+
for (const category of request.categories ?? []) {
|
|
90
|
+
url.searchParams.append("categories", category)
|
|
91
|
+
}
|
|
92
|
+
for (const tag of request.tags ?? []) {
|
|
93
|
+
url.searchParams.append("tags", tag)
|
|
94
|
+
}
|
|
95
|
+
if (request.order !== undefined) {
|
|
96
|
+
url.searchParams.set("order", request.order)
|
|
97
|
+
}
|
|
86
98
|
if (request.limit !== undefined) {
|
|
87
99
|
url.searchParams.set("limit", request.limit.toString())
|
|
88
100
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const packageName = "@nyc-transit-kit/soda3"
|
|
2
2
|
export { buildCatalogSearchUrl, catalogSearch } from "./catalog"
|
|
3
|
-
export type { Soda3ClientConfigShape, Soda3LayerOptions } from "./client"
|
|
3
|
+
export type { FetchImplementation, Soda3ClientConfigShape, Soda3LayerOptions } from "./client"
|
|
4
4
|
export {
|
|
5
5
|
Soda3ClientConfig,
|
|
6
6
|
Soda3HttpLive,
|
|
@@ -30,7 +30,10 @@ export {
|
|
|
30
30
|
TimeoutError
|
|
31
31
|
} from "./errors"
|
|
32
32
|
export { exportResponse } from "./export"
|
|
33
|
-
export {
|
|
33
|
+
export type { Soda3DatasetMetadataRequestInput } from "./metadata"
|
|
34
|
+
export { fetchDatasetMetadata } from "./metadata"
|
|
35
|
+
export type { Soda3CountRowsInput, Soda3QueryAllRowsInput } from "./query"
|
|
36
|
+
export { countRows, queryAllRows, queryRows } from "./query"
|
|
34
37
|
export type {
|
|
35
38
|
SocrataTimestampWindow,
|
|
36
39
|
SocrataYearMonth,
|
package/src/internal/bodies.ts
CHANGED
|
@@ -18,4 +18,6 @@ export const toExportBody = (request: Soda3ExportRequest) => ({
|
|
|
18
18
|
})
|
|
19
19
|
|
|
20
20
|
export const rangeHeader = (request: Soda3ExportRequest) =>
|
|
21
|
-
request.range === undefined
|
|
21
|
+
request.range === undefined
|
|
22
|
+
? undefined
|
|
23
|
+
: `bytes=${request.range.start}-${request.range.end ?? ""}`
|
package/src/internal/services.ts
CHANGED
|
@@ -6,11 +6,17 @@ import { Soda3HttpLive } from "./http"
|
|
|
6
6
|
export interface Soda3ClientConfigShape {
|
|
7
7
|
readonly appToken?: string
|
|
8
8
|
readonly retryTimes?: number
|
|
9
|
+
readonly retryDelayMs?: number
|
|
9
10
|
readonly timeoutMs?: number
|
|
10
11
|
}
|
|
11
12
|
|
|
13
|
+
export type FetchImplementation = (
|
|
14
|
+
input: string | URL | globalThis.Request,
|
|
15
|
+
init?: globalThis.RequestInit
|
|
16
|
+
) => Promise<globalThis.Response>
|
|
17
|
+
|
|
12
18
|
export interface Soda3LayerOptions extends Soda3ClientConfigShape {
|
|
13
|
-
readonly fetch?:
|
|
19
|
+
readonly fetch?: FetchImplementation
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
export class Soda3ClientConfig extends Context.Service<Soda3ClientConfig, Soda3ClientConfigShape>()(
|
|
@@ -25,14 +31,15 @@ export const soda3ConfigLayer = (options: Soda3ClientConfigShape = {}) =>
|
|
|
25
31
|
Layer.succeed(Soda3ClientConfig)({
|
|
26
32
|
retryTimes: options.retryTimes ?? 0,
|
|
27
33
|
...(options.appToken === undefined ? {} : { appToken: options.appToken }),
|
|
34
|
+
...(options.retryDelayMs === undefined ? {} : { retryDelayMs: options.retryDelayMs }),
|
|
28
35
|
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs })
|
|
29
36
|
})
|
|
30
37
|
|
|
31
|
-
export const soda3FetchLayer = (fetchImplementation?:
|
|
38
|
+
export const soda3FetchLayer = (fetchImplementation?: FetchImplementation) =>
|
|
32
39
|
fetchImplementation === undefined
|
|
33
40
|
? Soda3HttpLive
|
|
34
41
|
: FetchHttpClient.layer.pipe(
|
|
35
|
-
Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchImplementation))
|
|
42
|
+
Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchImplementation as typeof fetch))
|
|
36
43
|
)
|
|
37
44
|
|
|
38
45
|
export const soda3Layer = (options: Soda3LayerOptions = {}) =>
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import * as Duration from "effect/Duration"
|
|
1
2
|
import * as Effect from "effect/Effect"
|
|
3
|
+
import * as Schedule from "effect/Schedule"
|
|
2
4
|
import * as HttpClient from "effect/unstable/http/HttpClient"
|
|
3
5
|
import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
|
|
4
6
|
import type { Soda3ClientError } from "../errors"
|
|
@@ -20,6 +22,19 @@ export const addHeaders = (config: Soda3ClientConfigShape) => ({
|
|
|
20
22
|
|
|
21
23
|
export { retryableStatus }
|
|
22
24
|
|
|
25
|
+
const retryPolicy = (retryTimes: number, retryDelayMs: number | undefined) =>
|
|
26
|
+
retryDelayMs === undefined || retryDelayMs <= 0
|
|
27
|
+
? {
|
|
28
|
+
times: retryTimes,
|
|
29
|
+
while: isRetryableProviderError
|
|
30
|
+
}
|
|
31
|
+
: {
|
|
32
|
+
schedule: Schedule.exponential(Duration.millis(retryDelayMs)).pipe(
|
|
33
|
+
Schedule.both(Schedule.recurs(retryTimes))
|
|
34
|
+
),
|
|
35
|
+
while: isRetryableProviderError
|
|
36
|
+
}
|
|
37
|
+
|
|
23
38
|
export const requestWithPolicy = <A>(
|
|
24
39
|
operation: string,
|
|
25
40
|
effect: Effect.Effect<A, Soda3ClientError, HttpClient.HttpClient | Soda3ClientConfig>
|
|
@@ -30,10 +45,7 @@ export const requestWithPolicy = <A>(
|
|
|
30
45
|
const retried =
|
|
31
46
|
retryTimes > 0
|
|
32
47
|
? effect.pipe(
|
|
33
|
-
Effect.retry(
|
|
34
|
-
times: retryTimes,
|
|
35
|
-
while: isRetryableProviderError
|
|
36
|
-
}),
|
|
48
|
+
Effect.retry(retryPolicy(retryTimes, config.retryDelayMs)),
|
|
37
49
|
Effect.catchIf(isRetryableProviderError, (error) =>
|
|
38
50
|
Effect.fail(
|
|
39
51
|
RetryExhaustedError.make({
|
package/src/metadata.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Soda3CatalogResultFragment,
|
|
3
|
+
Soda3DatasetMetadataRequest,
|
|
4
|
+
type Soda3DatasetMetadataRequestInput
|
|
5
|
+
} from "@nyc-transit-kit/contracts/soda3"
|
|
6
|
+
import * as Effect from "effect/Effect"
|
|
7
|
+
import * as Schema from "effect/Schema"
|
|
8
|
+
import { catalogSearch } from "./catalog"
|
|
9
|
+
import { InvalidInputError, ProviderContractError } from "./errors"
|
|
10
|
+
|
|
11
|
+
export type { Soda3DatasetMetadataRequestInput }
|
|
12
|
+
|
|
13
|
+
const decodeDatasetMetadataRequest = (input: unknown) =>
|
|
14
|
+
Schema.decodeUnknownEffect(Soda3DatasetMetadataRequest)(input).pipe(
|
|
15
|
+
Effect.catchTag("SchemaError", (error) =>
|
|
16
|
+
Effect.fail(
|
|
17
|
+
InvalidInputError.make({
|
|
18
|
+
operation: "metadata",
|
|
19
|
+
message: error.message
|
|
20
|
+
})
|
|
21
|
+
)
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
const matchingResult = (results: ReadonlyArray<Soda3CatalogResultFragment>, datasetId: string) =>
|
|
26
|
+
results.find((result) => String(result.resource.id) === datasetId)
|
|
27
|
+
|
|
28
|
+
export const fetchDatasetMetadata = Effect.fn("Soda3.fetchDatasetMetadata")(function* (
|
|
29
|
+
input: Soda3DatasetMetadataRequestInput
|
|
30
|
+
) {
|
|
31
|
+
const request = yield* decodeDatasetMetadataRequest(input)
|
|
32
|
+
const response = yield* catalogSearch({
|
|
33
|
+
domain: request.domain,
|
|
34
|
+
ids: [request.datasetId],
|
|
35
|
+
limit: 1
|
|
36
|
+
})
|
|
37
|
+
const result = matchingResult(response.results, String(request.datasetId))
|
|
38
|
+
|
|
39
|
+
if (result === undefined) {
|
|
40
|
+
return yield* ProviderContractError.make({
|
|
41
|
+
operation: "metadata",
|
|
42
|
+
message: `Discovery metadata response did not include dataset ${request.datasetId}`
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return result
|
|
47
|
+
})
|
package/src/query.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
PositiveInteger,
|
|
3
|
+
type Soda3QueryRequestInput,
|
|
4
|
+
type Soda3Row
|
|
5
|
+
} from "@nyc-transit-kit/contracts/soda3"
|
|
2
6
|
import * as Effect from "effect/Effect"
|
|
7
|
+
import * as Schema from "effect/Schema"
|
|
3
8
|
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
|
|
4
9
|
import { buildQueryUrl, decodeSoda3QueryRequest } from "./endpoints"
|
|
5
|
-
import { InvalidInputError } from "./errors"
|
|
10
|
+
import { InvalidInputError, ProviderContractError } from "./errors"
|
|
6
11
|
import { toQueryBody } from "./internal/bodies"
|
|
7
12
|
import { decodeJson, decodeQueryResponse } from "./internal/responses"
|
|
8
13
|
import { Soda3ClientConfig } from "./internal/services"
|
|
@@ -10,6 +15,42 @@ import { addHeaders, executeRequest, requestWithPolicy } from "./internal/transp
|
|
|
10
15
|
|
|
11
16
|
export type { Soda3QueryRequestInput }
|
|
12
17
|
|
|
18
|
+
export type Soda3QueryAllRowsInput = Omit<Soda3QueryRequestInput, "page"> & {
|
|
19
|
+
readonly pageSize?: number
|
|
20
|
+
readonly maxRows?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type Soda3CountRowsInput = {
|
|
24
|
+
readonly domain: string
|
|
25
|
+
readonly datasetId: string
|
|
26
|
+
readonly where?: string
|
|
27
|
+
readonly parameters?: Readonly<Record<string, unknown>>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const decodeHelperPositiveInteger = (name: string, value: number) =>
|
|
31
|
+
Schema.decodeUnknownEffect(PositiveInteger)(value).pipe(
|
|
32
|
+
Effect.catchTag("SchemaError", (error) =>
|
|
33
|
+
Effect.fail(
|
|
34
|
+
InvalidInputError.make({
|
|
35
|
+
operation: "query",
|
|
36
|
+
message: `Invalid ${name}: ${error.message}`
|
|
37
|
+
})
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const decodeOptionalHelperPositiveInteger = (name: string, value: number | undefined) =>
|
|
43
|
+
value === undefined ? Effect.void : decodeHelperPositiveInteger(name, value)
|
|
44
|
+
|
|
45
|
+
const decodeCountValue = (value: unknown) => {
|
|
46
|
+
const parsed =
|
|
47
|
+
typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN
|
|
48
|
+
|
|
49
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const countColumnName = "count"
|
|
53
|
+
|
|
13
54
|
export const queryRows = Effect.fn("Soda3.queryRows")(function* (input: Soda3QueryRequestInput) {
|
|
14
55
|
const config = yield* Soda3ClientConfig
|
|
15
56
|
const queryRequest = yield* decodeSoda3QueryRequest(input)
|
|
@@ -34,3 +75,58 @@ export const queryRows = Effect.fn("Soda3.queryRows")(function* (input: Soda3Que
|
|
|
34
75
|
)
|
|
35
76
|
)
|
|
36
77
|
})
|
|
78
|
+
|
|
79
|
+
export const queryAllRows = Effect.fn("Soda3.queryAllRows")(function* (
|
|
80
|
+
input: Soda3QueryAllRowsInput
|
|
81
|
+
) {
|
|
82
|
+
const { pageSize: rawPageSize = 1000, maxRows: rawMaxRows, ...queryInput } = input
|
|
83
|
+
const pageSize = yield* decodeHelperPositiveInteger("pageSize", rawPageSize)
|
|
84
|
+
const maxRows = yield* decodeOptionalHelperPositiveInteger("maxRows", rawMaxRows)
|
|
85
|
+
const rows: Array<Soda3Row> = []
|
|
86
|
+
let pageNumber = 1
|
|
87
|
+
|
|
88
|
+
while (true) {
|
|
89
|
+
const response = yield* queryRows({
|
|
90
|
+
...queryInput,
|
|
91
|
+
page: {
|
|
92
|
+
pageNumber,
|
|
93
|
+
pageSize
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
rows.push(...response.rows)
|
|
97
|
+
|
|
98
|
+
if (maxRows !== undefined && rows.length > maxRows) {
|
|
99
|
+
return yield* InvalidInputError.make({
|
|
100
|
+
operation: "query",
|
|
101
|
+
message: `SODA3 query exceeded maxRows=${maxRows}; narrow the query or raise maxRows`
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (response.rows.length < pageSize) {
|
|
106
|
+
return rows
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
pageNumber += 1
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
export const countRows = Effect.fn("Soda3.countRows")(function* (input: Soda3CountRowsInput) {
|
|
114
|
+
const query = `SELECT count(*)${input.where === undefined ? "" : ` WHERE ${input.where}`}`
|
|
115
|
+
const response = yield* queryRows({
|
|
116
|
+
domain: input.domain,
|
|
117
|
+
datasetId: input.datasetId,
|
|
118
|
+
query,
|
|
119
|
+
...(input.parameters === undefined ? {} : { parameters: input.parameters }),
|
|
120
|
+
includeSynthetic: false
|
|
121
|
+
})
|
|
122
|
+
const count = decodeCountValue(response.rows[0]?.[countColumnName])
|
|
123
|
+
|
|
124
|
+
if (count === undefined) {
|
|
125
|
+
return yield* ProviderContractError.make({
|
|
126
|
+
operation: "query",
|
|
127
|
+
message: "SODA3 count(*) response did not include a numeric count"
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return count
|
|
132
|
+
})
|