@nyc-transit-kit/soda3 0.2.0 → 0.3.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/README.md CHANGED
@@ -1,30 +1,47 @@
1
1
  # @nyc-transit-kit/soda3
2
2
 
3
- Generic Effect-native client for Socrata SODA3 query, export, and Discovery
4
- catalog APIs.
3
+ Effect client for Socrata on any domain: SODA3 query and CSV export, Discovery
4
+ catalog search, and SoQL built as `effect/sql` Statement fragments whose values
5
+ are written as SoQL literals. It speaks SODA3 alone; the legacy row endpoints
6
+ are not supported.
5
7
 
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
8
+ ```sh
9
+ bun add effect@^4.0.6 @nyc-transit-kit/soda3
10
+ ```
10
11
 
11
12
  ```ts
12
- import { soda3Layer } from "@nyc-transit-kit/soda3/client"
13
- import { queryRows } from "@nyc-transit-kit/soda3/query"
13
+ import * as Socrata from "@nyc-transit-kit/soda3/Socrata"
14
+ import * as Soda3Client from "@nyc-transit-kit/soda3/Soda3Client"
15
+ import * as Soql from "@nyc-transit-kit/soda3/Soql"
14
16
  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 })))
17
+ import * as FetchHttpClient from "effect/http/FetchHttpClient"
18
+ import * as Layer from "effect/Layer"
19
+ import * as Redacted from "effect/Redacted"
20
+
21
+ const rows = Effect.gen(function* () {
22
+ const soda = yield* Soda3Client.Soda3Client
23
+ return yield* soda.query({
24
+ domain: Socrata.SocrataDomain.make("data.ny.gov"),
25
+ datasetId: Socrata.SocrataDatasetId.make("f462-ka72"),
26
+ query: Soql.soql`SELECT dataset_title, status WHERE ${Soql.eq("agency", "New York City Transit")} LIMIT 5`
27
+ })
28
+ })
29
+
30
+ export const program = rows.pipe(
31
+ Effect.provide(
32
+ Soda3Client.layer({ appToken: Redacted.make("your-app-token") }).pipe(
33
+ Layer.provide(FetchHttpClient.layer)
34
+ )
35
+ )
36
+ )
21
37
  ```
22
38
 
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
39
+ - SODA3 refuses query and export without an app token, so
40
+ `Soda3Client.layer` requires one. `Discovery.search` needs none.
41
+ - Refusals fail with `SocrataError`, which keeps Socrata's own sentence, such
42
+ as "This request must be authenticated or have an application token".
43
+ - Nothing retries or times out on its own; compose `HttpClient.retryTransient`
44
+ and `Effect.timeout` where you run it.
45
+ - Modules: `/Soda3Client`, `/Dataset`, `/Discovery`, `/Soql`, `/Socrata`.
28
46
 
29
- Each `nyc-transit-kit` release pins one exact Effect 4 beta. Match the Effect
30
- version documented in `docs/getting-started.md`.
47
+ See the [API reference](https://github.com/mannyc2/nyc-transit-kit/blob/main/docs/api-reference.md).
package/package.json CHANGED
@@ -1,20 +1,16 @@
1
1
  {
2
2
  "name": "@nyc-transit-kit/soda3",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "exports": {
8
8
  ".": "./src/index.ts",
9
- "./catalog": "./src/catalog.ts",
10
- "./client": "./src/client.ts",
11
- "./endpoints": "./src/endpoints.ts",
12
- "./errors": "./src/errors.ts",
13
- "./export": "./src/export.ts",
14
- "./metadata": "./src/metadata.ts",
15
- "./query": "./src/query.ts",
16
- "./soql": "./src/soql.ts",
17
- "./transport": "./src/transport.ts"
9
+ "./Dataset": "./src/Dataset.ts",
10
+ "./Discovery": "./src/Discovery.ts",
11
+ "./Socrata": "./src/Socrata.ts",
12
+ "./Soda3Client": "./src/Soda3Client.ts",
13
+ "./Soql": "./src/Soql.ts"
18
14
  },
19
15
  "types": "./src/index.ts",
20
16
  "files": [
@@ -22,17 +18,16 @@
22
18
  "README.md",
23
19
  "src"
24
20
  ],
25
- "scripts": {
26
- "build": "tsc -b",
27
- "test": "bun test ./test"
28
- },
29
- "dependencies": {
30
- "@nyc-transit-kit/contracts": "0.2.0",
31
- "effect": "4.0.0-beta.92"
32
- },
33
21
  "publishConfig": {
34
22
  "access": "public"
35
23
  },
24
+ "peerDependencies": {
25
+ "effect": "^4.0.6"
26
+ },
27
+ "devDependencies": {
28
+ "@nyc-transit-kit/fixtures": "0.3.0",
29
+ "effect": "4.0.6"
30
+ },
36
31
  "repository": {
37
32
  "type": "git",
38
33
  "url": "git+https://github.com/mannyc2/nyc-transit-kit.git",
package/src/Dataset.ts ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Curated datasets: a Socrata dataset together with the schema of one full row,
3
+ * queried and decoded as typed rows.
4
+ */
5
+ import * as Effect from "effect/Effect"
6
+ import * as Schema from "effect/Schema"
7
+ import * as Stream from "effect/Stream"
8
+ import type { DatasetRef } from "./Socrata"
9
+ import * as Soda3Client from "./Soda3Client"
10
+ import * as Soql from "./Soql"
11
+
12
+ /** A Socrata dataset and the schema of one full row. */
13
+ export interface Dataset<
14
+ Row extends Schema.Codec<unknown, unknown> = Schema.Codec<unknown, unknown>
15
+ > extends DatasetRef {
16
+ readonly title: string
17
+ readonly description: string
18
+ readonly row: Row
19
+ /** The column that orders rows in time, when there is one. `stream` pages in its order. */
20
+ readonly timestampColumn?: string
21
+ }
22
+
23
+ /**
24
+ * Typed rows. A row schema describes whole rows, so typed queries always
25
+ * `SELECT *`; use `Soda3Client` for projections and aggregates.
26
+ */
27
+ export const query = Effect.fn("Dataset.query")(function* <
28
+ Row extends Schema.Codec<unknown, unknown>
29
+ >(dataset: Dataset<Row>, clauses: Soql.Clauses = {}) {
30
+ const soda = yield* Soda3Client.Soda3Client
31
+ const rows = yield* soda.query({
32
+ domain: dataset.domain,
33
+ datasetId: dataset.datasetId,
34
+ query: Soql.selectAll(clauses)
35
+ })
36
+ return yield* Schema.decodeUnknownEffect(Schema.Array(dataset.row))(rows)
37
+ })
38
+
39
+ const oldestFirst = (dataset: Pick<Dataset, "timestampColumn">): Soql.OrderBy | undefined =>
40
+ dataset.timestampColumn === undefined ? undefined : { column: dataset.timestampColumn }
41
+
42
+ /**
43
+ * Every matching row, one page per request, decoded as it arrives. Unless
44
+ * `clauses` give an `orderBy`, rows come oldest first by the dataset's
45
+ * `timestampColumn`, when it has one, so rows added while you page land after
46
+ * the pages already read. Unordered, SODA3 served Traffic Speeds newest first
47
+ * and page 2 repeated page 1; see `Soda3Client.streamRows`.
48
+ */
49
+ export const stream = <Row extends Schema.Codec<unknown, unknown>>(
50
+ dataset: Dataset<Row>,
51
+ clauses: Omit<Soql.Clauses, "limit"> = {},
52
+ pageSize?: number
53
+ ) =>
54
+ Soda3Client.streamRows(
55
+ {
56
+ domain: dataset.domain,
57
+ datasetId: dataset.datasetId,
58
+ query: Soql.selectAll({ ...clauses, orderBy: clauses.orderBy ?? oldestFirst(dataset) })
59
+ },
60
+ pageSize
61
+ ).pipe(Stream.mapEffect((row) => Schema.decodeUnknownEffect(dataset.row)(row)))
62
+
63
+ /** The dataset's SODA3 export as CSV bytes, narrowed by `body.query` when given. */
64
+ export const exportBytes = (
65
+ dataset: DatasetRef,
66
+ body: Omit<Soda3Client.ExportRequest, "domain" | "datasetId"> = {}
67
+ ) =>
68
+ Stream.service(Soda3Client.Soda3Client).pipe(
69
+ Stream.flatMap((soda) =>
70
+ soda.export({ ...body, domain: dataset.domain, datasetId: dataset.datasetId })
71
+ )
72
+ )
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The Socrata Discovery API: catalog search and dataset metadata. It needs no
3
+ * app token.
4
+ */
5
+ import * as Effect from "effect/Effect"
6
+ import * as HttpClient from "effect/http/HttpClient"
7
+ import * as HttpClientResponse from "effect/http/HttpClientResponse"
8
+ import * as Schema from "effect/Schema"
9
+ import { type DatasetRef, filterStatusOk, SocrataDatasetId, SocrataDomain } from "./Socrata"
10
+
11
+ export const url = "https://api.us.socrata.com/api/catalog/v1"
12
+
13
+ const Resource = Schema.Struct({
14
+ id: SocrataDatasetId,
15
+ name: Schema.String,
16
+ description: Schema.String,
17
+ type: Schema.String,
18
+ attribution: Schema.NullOr(Schema.String),
19
+ updatedAt: Schema.String,
20
+ createdAt: Schema.String,
21
+ data_updated_at: Schema.String,
22
+ metadata_updated_at: Schema.String,
23
+ columns_name: Schema.Array(Schema.String),
24
+ columns_field_name: Schema.Array(Schema.String),
25
+ columns_datatype: Schema.Array(Schema.String),
26
+ columns_description: Schema.Array(Schema.String)
27
+ })
28
+
29
+ export const CatalogResult = Schema.Struct({
30
+ resource: Resource,
31
+ classification: Schema.Struct({
32
+ categories: Schema.Array(Schema.String),
33
+ tags: Schema.Array(Schema.String),
34
+ domain_category: Schema.optionalKey(Schema.String),
35
+ domain_tags: Schema.Array(Schema.String)
36
+ }),
37
+ metadata: Schema.Struct({ domain: SocrataDomain }),
38
+ permalink: Schema.String,
39
+ link: Schema.String
40
+ })
41
+ export type CatalogResult = typeof CatalogResult.Type
42
+
43
+ /** Response of `GET https://api.us.socrata.com/api/catalog/v1`. */
44
+ export const SearchResponse = Schema.Struct({
45
+ results: Schema.Array(CatalogResult),
46
+ resultSetSize: Schema.Int
47
+ })
48
+ export type SearchResponse = typeof SearchResponse.Type
49
+
50
+ export class DatasetNotFound extends Schema.TaggedError<DatasetNotFound>()("DatasetNotFound", {
51
+ domain: SocrataDomain,
52
+ datasetId: SocrataDatasetId
53
+ }) {
54
+ override get message() {
55
+ return `${this.domain} has no public dataset ${this.datasetId}`
56
+ }
57
+ }
58
+
59
+ export interface Search {
60
+ readonly domain: SocrataDomain
61
+ readonly query?: string | undefined
62
+ readonly ids?: ReadonlyArray<SocrataDatasetId> | undefined
63
+ readonly categories?: ReadonlyArray<string> | undefined
64
+ readonly tags?: ReadonlyArray<string> | undefined
65
+ readonly order?: string | undefined
66
+ readonly limit?: number | undefined
67
+ readonly offset?: number | undefined
68
+ }
69
+
70
+ /** Searches the public datasets of one domain. */
71
+ export const search = Effect.fn("Discovery.search")(function* (search: Search) {
72
+ const client = yield* HttpClient.HttpClient
73
+ const response = yield* client
74
+ .get(url, {
75
+ acceptJson: true,
76
+ urlParams: {
77
+ domains: search.domain,
78
+ search_context: search.domain,
79
+ only: "dataset",
80
+ q: search.query,
81
+ ids: search.ids,
82
+ categories: search.categories,
83
+ tags: search.tags,
84
+ order: search.order,
85
+ limit: search.limit,
86
+ offset: search.offset
87
+ }
88
+ })
89
+ .pipe(Effect.flatMap(filterStatusOk))
90
+ return yield* HttpClientResponse.schemaBodyJson(SearchResponse)(response)
91
+ })
92
+
93
+ /** The Discovery entry of one dataset. */
94
+ export const metadata = Effect.fn("Discovery.metadata")(function* (ref: DatasetRef) {
95
+ const { results } = yield* search({ domain: ref.domain, ids: [ref.datasetId] })
96
+ const result = results.find((candidate) => candidate.resource.id === ref.datasetId)
97
+ return result ?? (yield* new DatasetNotFound({ domain: ref.domain, datasetId: ref.datasetId }))
98
+ })
99
+
100
+ /** Discovery lists columns as four parallel arrays; this zips them. */
101
+ export const columns = ({ resource }: CatalogResult) =>
102
+ resource.columns_field_name.map((field, index) => ({
103
+ field,
104
+ name: resource.columns_name[index] ?? field,
105
+ datatype: resource.columns_datatype[index] ?? "",
106
+ description: resource.columns_description[index] ?? ""
107
+ }))
package/src/Socrata.ts ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * What every Socrata API shares: domains, dataset ids, the floating timestamps
3
+ * in rows, and the error Socrata answers with.
4
+ */
5
+ import * as Effect from "effect/Effect"
6
+ import * as HttpClientResponse from "effect/http/HttpClientResponse"
7
+ import * as Option from "effect/Option"
8
+ import * as Schema from "effect/Schema"
9
+
10
+ /** A Socrata domain without protocol or path, such as `data.cityofnewyork.us`. */
11
+ export const SocrataDomain = Schema.String.pipe(
12
+ Schema.check(
13
+ Schema.isPattern(/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/, {
14
+ message: "Expected a Socrata domain without a protocol or path"
15
+ })
16
+ ),
17
+ Schema.brand("SocrataDomain")
18
+ )
19
+ export type SocrataDomain = typeof SocrataDomain.Type
20
+
21
+ /** The eight-character id of a dataset, such as `i4gi-tjb9`. */
22
+ export const SocrataDatasetId = Schema.String.pipe(
23
+ Schema.check(
24
+ Schema.isPattern(/^[a-z0-9]{4}-[a-z0-9]{4}$/, {
25
+ message: "Expected an eight-character Socrata dataset id such as abcd-1234"
26
+ })
27
+ ),
28
+ Schema.brand("SocrataDatasetId")
29
+ )
30
+ export type SocrataDatasetId = typeof SocrataDatasetId.Type
31
+
32
+ /** One dataset on one domain. */
33
+ export interface DatasetRef {
34
+ readonly domain: SocrataDomain
35
+ readonly datasetId: SocrataDatasetId
36
+ }
37
+
38
+ /** The dataset's page, `https://{domain}/d/{id}`. */
39
+ export const datasetUrl = (ref: DatasetRef) => `https://${ref.domain}/d/${ref.datasetId}`
40
+
41
+ const isCalendarDate = (value: string) => {
42
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
43
+ if (match === null) {
44
+ return false
45
+ }
46
+ const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])]
47
+ const date = new Date(Date.UTC(year, month - 1, day))
48
+ return (
49
+ date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
50
+ )
51
+ }
52
+
53
+ /** A calendar date, `YYYY-MM-DD`. */
54
+ export const IsoDate = Schema.String.pipe(
55
+ Schema.check(
56
+ Schema.makeFilter<string>(
57
+ (value) => isCalendarDate(value) || "Expected an ISO calendar date in YYYY-MM-DD format"
58
+ )
59
+ ),
60
+ Schema.brand("IsoDate")
61
+ )
62
+ export type IsoDate = typeof IsoDate.Type
63
+
64
+ /** Floating timestamps carry no zone: `2026-10-26T23:02:06.000` is New York local time. */
65
+ export const FloatingTimestamp = Schema.String.check(
66
+ Schema.isPattern(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}$/, {
67
+ message: "Expected a Socrata floating timestamp such as 2026-10-26T23:02:06.000"
68
+ })
69
+ )
70
+
71
+ export const MultiLineString = Schema.Struct({
72
+ type: Schema.Literal("MultiLineString"),
73
+ coordinates: Schema.Array(Schema.Array(Schema.Array(Schema.Finite)))
74
+ })
75
+
76
+ /** Socrata answered with a non-2xx status. `message` is Socrata's own sentence when it sent one. */
77
+ export class SocrataError extends Schema.TaggedError<SocrataError>()("SocrataError", {
78
+ status: Schema.Int,
79
+ code: Schema.optional(Schema.String),
80
+ message: Schema.String
81
+ }) {}
82
+
83
+ /** Non-2xx bodies: SODA3 sends `{ code, error: true, message }`, Discovery sends `{ error }`. */
84
+ export const SocrataErrorBody = Schema.Union([
85
+ Schema.Struct({ code: Schema.optionalKey(Schema.String), message: Schema.String }),
86
+ Schema.Struct({ error: Schema.String })
87
+ ])
88
+
89
+ const decodeErrorBody = Schema.decodeUnknownOption(Schema.fromJsonString(SocrataErrorBody))
90
+
91
+ const socrataError = (status: number, text: string) =>
92
+ Option.match(decodeErrorBody(text), {
93
+ onNone: () => new SocrataError({ status, message: `Socrata answered HTTP ${status}` }),
94
+ onSome: (body) =>
95
+ "message" in body
96
+ ? new SocrataError({ status, code: body.code, message: body.message })
97
+ : new SocrataError({ status, message: body.error })
98
+ })
99
+
100
+ /**
101
+ * Passes 2xx responses through and fails the rest with a `SocrataError`. Unlike
102
+ * `HttpClient.filterStatusOk` it reads the body, which holds the only useful
103
+ * sentence, such as "This request must be authenticated or have an application token".
104
+ */
105
+ export const filterStatusOk = (
106
+ response: HttpClientResponse.HttpClientResponse
107
+ ): Effect.Effect<HttpClientResponse.HttpClientResponse, SocrataError> =>
108
+ HttpClientResponse.matchStatus(response, {
109
+ "2xx": (ok) => Effect.succeed(ok),
110
+ orElse: (failed) =>
111
+ failed.text.pipe(
112
+ Effect.orElseSucceed(() => ""),
113
+ Effect.flatMap((text) => Effect.fail(socrataError(failed.status, text)))
114
+ )
115
+ })
@@ -0,0 +1,224 @@
1
+ /**
2
+ * SODA3 `query.json` and `export.csv` on any Socrata domain.
3
+ */
4
+ import * as Context from "effect/Context"
5
+ import * as Effect from "effect/Effect"
6
+ import * as HttpClient from "effect/http/HttpClient"
7
+ import type * as HttpClientError from "effect/http/HttpClientError"
8
+ import * as HttpClientRequest from "effect/http/HttpClientRequest"
9
+ import * as HttpClientResponse from "effect/http/HttpClientResponse"
10
+ import * as Layer from "effect/Layer"
11
+ import * as Option from "effect/Option"
12
+ import * as Redacted from "effect/Redacted"
13
+ import * as Schema from "effect/Schema"
14
+ import * as Stream from "effect/Stream"
15
+ import * as Struct from "effect/Struct"
16
+ import { type DatasetRef, filterStatusOk, type SocrataError } from "./Socrata"
17
+ import * as Soql from "./Soql"
18
+
19
+ export const OrderingSpecifier = Schema.Literals(["total", "discard"])
20
+ export type OrderingSpecifier = typeof OrderingSpecifier.Type
21
+
22
+ export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
23
+
24
+ export const Page = Schema.Struct({ pageNumber: PositiveInt, pageSize: PositiveInt })
25
+ export type Page = typeof Page.Type
26
+
27
+ /** Only parameterized views read `parameters`; ordinary queries carry their values in the SoQL. */
28
+ export const Parameters = Schema.Record(
29
+ Schema.String,
30
+ Schema.Union([Schema.String, Schema.Finite, Schema.Boolean])
31
+ )
32
+
33
+ /** Body of `POST /api/v3/views/{id}/query.json`. */
34
+ export const QueryBody = Schema.Struct({
35
+ query: Schema.String,
36
+ page: Schema.optionalKey(Page),
37
+ parameters: Schema.optionalKey(Parameters),
38
+ timeout: Schema.optionalKey(PositiveInt),
39
+ includeSystem: Schema.optionalKey(Schema.Boolean),
40
+ includeSynthetic: Schema.optionalKey(Schema.Boolean),
41
+ orderingSpecifier: Schema.optionalKey(OrderingSpecifier)
42
+ })
43
+ export type QueryBody = typeof QueryBody.Type
44
+
45
+ /**
46
+ * Body of `POST /api/v3/views/{id}/export.csv`. SODA3's documentation shapes
47
+ * the file with `serializationOptions`, such as `{ separator: "\t", bom: true }`.
48
+ */
49
+ export const ExportBody = Schema.Struct({
50
+ query: Schema.optionalKey(Schema.String),
51
+ parameters: Schema.optionalKey(Parameters),
52
+ timeout: Schema.optionalKey(PositiveInt),
53
+ orderingSpecifier: Schema.optionalKey(OrderingSpecifier),
54
+ serializationOptions: Schema.optionalKey(
55
+ Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Boolean]))
56
+ )
57
+ })
58
+ export type ExportBody = typeof ExportBody.Type
59
+
60
+ export const Row = Schema.Record(Schema.String, Schema.Unknown)
61
+ export type Row = typeof Row.Type
62
+
63
+ /**
64
+ * A bare array of rows. Numbers arrive as text, null cells are left out, and
65
+ * unless `includeSystem` or `includeSynthetic` is false each row ends with the
66
+ * system columns `:id`, `:version`, `:created_at` and `:updated_at`.
67
+ */
68
+ export const QueryResponse = Schema.Array(Row)
69
+
70
+ /** A query: the body with SoQL as text or as a fragment. */
71
+ export interface QueryRequest extends DatasetRef, Omit<QueryBody, "query"> {
72
+ readonly query: string | Soql.Fragment
73
+ }
74
+
75
+ /**
76
+ * A CSV export, narrowed by `query` when given. CSV is the format SODA3
77
+ * exported on 2026-11-01; it refused `json` and `geojson` with a 406 on every
78
+ * dataset tried.
79
+ */
80
+ export interface ExportRequest extends DatasetRef, Omit<ExportBody, "query"> {
81
+ readonly query?: string | Soql.Fragment
82
+ /**
83
+ * Ask for a byte range. On 2026-11-01 SODA3 ignored it and sent the whole
84
+ * export with a 200; `ntk socrata range-probe` checks again.
85
+ */
86
+ readonly range?: { readonly start: number; readonly end?: number }
87
+ }
88
+
89
+ export type QueryError =
90
+ | HttpClientError.HttpClientError
91
+ | SocrataError
92
+ | Soql.SoqlError
93
+ | Schema.SchemaError
94
+
95
+ export type ExportError = HttpClientError.HttpClientError | SocrataError | Soql.SoqlError
96
+
97
+ export const viewUrl = (ref: DatasetRef, file: string) =>
98
+ `https://${ref.domain}/api/v3/views/${ref.datasetId}/${file}`
99
+
100
+ /**
101
+ * SODA3 query, and export as CSV. Socrata refuses both without an app token,
102
+ * so the layer requires one. Retries and timeouts belong to the caller's
103
+ * HttpClient. SODA3 has no default limit: a query without `LIMIT` or `page`
104
+ * returns every matching row.
105
+ */
106
+ export class Soda3Client extends Context.Service<
107
+ Soda3Client,
108
+ {
109
+ readonly query: (request: QueryRequest) => Effect.Effect<ReadonlyArray<Row>, QueryError>
110
+ readonly export: (request: ExportRequest) => Stream.Stream<Uint8Array, ExportError>
111
+ }
112
+ >()("@nyc-transit-kit/soda3/Soda3Client") {}
113
+
114
+ const soqlText = (query: string | Soql.Fragment) =>
115
+ typeof query === "string" ? Effect.succeed(query) : Soql.compile(query)
116
+
117
+ // Only these fields reach the body, so a request built by spreading a richer
118
+ // object, such as a curated `Dataset`, sends nothing else.
119
+ const queryOptions = [
120
+ "page",
121
+ "parameters",
122
+ "timeout",
123
+ "includeSystem",
124
+ "includeSynthetic",
125
+ "orderingSpecifier"
126
+ ] as const
127
+ const exportOptions = [
128
+ "parameters",
129
+ "timeout",
130
+ "orderingSpecifier",
131
+ "serializationOptions"
132
+ ] as const
133
+
134
+ const exportBody = Effect.fnUntraced(function* (request: ExportRequest) {
135
+ const body = Struct.pick(request, exportOptions)
136
+ return request.query === undefined ? body : { ...body, query: yield* soqlText(request.query) }
137
+ })
138
+
139
+ export const layer = (options: { readonly appToken: Redacted.Redacted }) =>
140
+ Layer.effect(
141
+ Soda3Client,
142
+ Effect.gen(function* () {
143
+ const client = (yield* HttpClient.HttpClient).pipe(
144
+ HttpClient.mapRequest(
145
+ HttpClientRequest.setHeader("x-app-token", Redacted.value(options.appToken))
146
+ ),
147
+ HttpClient.transformResponse(Effect.flatMap(filterStatusOk))
148
+ )
149
+ const post = (url: string, body: object, headers: Readonly<Record<string, string>> = {}) =>
150
+ client.execute(
151
+ HttpClientRequest.post(url, { headers }).pipe(HttpClientRequest.bodyJsonUnsafe(body))
152
+ )
153
+
154
+ const query = Effect.fn("Soda3Client.query")(function* (request: QueryRequest) {
155
+ const response = yield* post(
156
+ viewUrl(request, "query.json"),
157
+ { ...Struct.pick(request, queryOptions), query: yield* soqlText(request.query) },
158
+ { accept: "application/json" }
159
+ )
160
+ return yield* HttpClientResponse.schemaBodyJson(QueryResponse)(response)
161
+ })
162
+
163
+ const exportBytes = (request: ExportRequest) =>
164
+ Stream.unwrap(
165
+ Effect.map(exportBody(request), (body) =>
166
+ HttpClientResponse.stream(
167
+ post(
168
+ viewUrl(request, "export.csv"),
169
+ body,
170
+ request.range === undefined
171
+ ? {}
172
+ : { range: `bytes=${request.range.start}-${request.range.end ?? ""}` }
173
+ )
174
+ )
175
+ )
176
+ ).pipe(Stream.withSpan("Soda3Client.export"))
177
+
178
+ return Soda3Client.of({ query, export: exportBytes })
179
+ })
180
+ )
181
+
182
+ /**
183
+ * Every row of a query, one page per request. Pages end at the first short page.
184
+ *
185
+ * A page is a slice of the query's order, so order by something that puts new
186
+ * rows last, such as the dataset's timestamp column, when the dataset changes
187
+ * while you page. Without an `ORDER BY`, SODA3 served Traffic Speeds newest
188
+ * first, requests seconds apart disagreed on the newest rows, and page 2
189
+ * repeated page 1. SODA3 refuses paging with `orderingSpecifier: "discard"`,
190
+ * so this takes none.
191
+ */
192
+ export const streamRows = (
193
+ request: Omit<QueryRequest, "page" | "orderingSpecifier">,
194
+ pageSize = 1000
195
+ ) =>
196
+ Stream.paginate(1, (pageNumber: number) =>
197
+ Effect.gen(function* () {
198
+ const soda = yield* Soda3Client
199
+ const rows = yield* soda.query({ ...request, page: { pageNumber, pageSize } })
200
+ const next = rows.length < pageSize ? Option.none<number>() : Option.some(pageNumber + 1)
201
+ return [rows, next] as const
202
+ })
203
+ )
204
+
205
+ // SODA3 sends `count(*)` as text, like every number in a row.
206
+ const CountRows = Schema.Tuple([Schema.Struct({ count: Schema.FiniteFromString })])
207
+
208
+ /** `count(*)` of the rows that match `where`, or of all rows. */
209
+ export const countRows = Effect.fn("Soda3Client.countRows")(function* (
210
+ ref: DatasetRef,
211
+ where?: Soql.Fragment
212
+ ) {
213
+ const soda = yield* Soda3Client
214
+ const rows = yield* soda.query({
215
+ ...ref,
216
+ query:
217
+ where === undefined
218
+ ? Soql.soql`SELECT count(*) AS count`
219
+ : Soql.soql`SELECT count(*) AS count WHERE ${where}`,
220
+ includeSynthetic: false
221
+ })
222
+ const [row] = yield* Schema.decodeUnknownEffect(CountRows)(rows)
223
+ return row.count
224
+ })