@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 CHANGED
@@ -1,5 +1,30 @@
1
1
  # @nyc-transit-kit/soda3
2
2
 
3
- Part of `nyc-transit-kit`, an Effect-native Bun toolkit for official NYC and MTA transit data APIs.
3
+ Generic Effect-native client for Socrata SODA3 query, export, and Discovery
4
+ catalog APIs.
4
5
 
5
- See the repository README and docs/api-reference.md for public import paths, CLI commands, and release notes.
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",
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.3",
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 { Soda3ClientConfigShape, Soda3LayerOptions } from "./internal/services"
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 { queryRows } from "./query"
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 { queryRows } from "./query"
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,
@@ -18,4 +18,6 @@ export const toExportBody = (request: Soda3ExportRequest) => ({
18
18
  })
19
19
 
20
20
  export const rangeHeader = (request: Soda3ExportRequest) =>
21
- request.range === undefined ? undefined : `bytes=${request.range.start}-${request.range.end}`
21
+ request.range === undefined
22
+ ? undefined
23
+ : `bytes=${request.range.start}-${request.range.end ?? ""}`
@@ -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?: typeof 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?: typeof fetch) =>
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({
@@ -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 type { Soda3QueryRequestInput } from "@nyc-transit-kit/contracts/soda3"
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
+ })