@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/src/Soql.ts ADDED
@@ -0,0 +1,200 @@
1
+ /**
2
+ * SoQL as `effect/sql` fragments.
3
+ *
4
+ * Text written in a `soql` template is trusted SoQL. Every interpolated value
5
+ * becomes a quoted literal and every `identifier` a backticked column, so input
6
+ * can never change the shape of a query. SODA3 has no bind parameters (`:name`
7
+ * names a system column), so `compile` writes the values into the text.
8
+ */
9
+ import * as Effect from "effect/Effect"
10
+ import * as Schema from "effect/Schema"
11
+ import * as Statement from "effect/sql/Statement"
12
+ import type { IsoDate } from "./Socrata"
13
+
14
+ export type Fragment = Statement.Fragment
15
+
16
+ /** A value SoQL can write as a literal. Timestamps are strings such as `2026-10-01T00:00:00`. */
17
+ export type Value = string | number | boolean
18
+
19
+ /** A fragment that cannot be written as SoQL, or clauses that make no sense. */
20
+ export class SoqlError extends Schema.TaggedError<SoqlError>()("SoqlError", {
21
+ message: Schema.String
22
+ }) {}
23
+
24
+ /** A column or alias, written as `` `name` `` so that reserved words such as `from` work. */
25
+ export const identifier = (name: string): Statement.Identifier => Statement.identifier(name)
26
+
27
+ /**
28
+ * `` soql`speed > ${limit}` ``. The text is SoQL; each interpolation is a value,
29
+ * an `identifier`, or another fragment spliced in place.
30
+ */
31
+ export const soql = (
32
+ strings: TemplateStringsArray,
33
+ ...args: ReadonlyArray<Value | Fragment | Statement.Segment>
34
+ ): Fragment => {
35
+ const segments: Array<Statement.Segment> = []
36
+ strings.forEach((text, index) => {
37
+ if (text !== "") {
38
+ segments.push(Statement.literal(text))
39
+ }
40
+ if (index < args.length) {
41
+ const arg = args[index]
42
+ if (Statement.isFragment(arg)) {
43
+ segments.push(...arg.segments)
44
+ } else if (typeof arg === "object") {
45
+ segments.push(arg)
46
+ } else if (arg !== undefined) {
47
+ segments.push(Statement.parameter(arg))
48
+ }
49
+ }
50
+ })
51
+ return Statement.fragment(segments)
52
+ }
53
+
54
+ const quote = (text: string) => `'${text.replaceAll("'", "''")}'`
55
+
56
+ const write = (value: unknown) => (typeof value === "string" ? quote(value) : String(value))
57
+
58
+ const compiler = Statement.makeCompiler({
59
+ // `Statement` knows no SoQL dialect. The name only matters to dialect switches, which SoQL never uses.
60
+ dialect: "pg",
61
+ placeholder: (_index, value) => write(value),
62
+ onIdentifier: (name) => `\`${name}\``,
63
+ onRecordUpdate: () => ["", []],
64
+ onCustom: () => ["", []]
65
+ })
66
+
67
+ const invalidValue = (value: unknown) =>
68
+ typeof value === "string" ||
69
+ typeof value === "boolean" ||
70
+ (typeof value === "number" && Number.isFinite(value))
71
+ ? undefined
72
+ : `SoQL has no literal for ${String(value)}`
73
+
74
+ const invalidSegment = (segment: Statement.Segment): string | undefined => {
75
+ switch (segment._tag) {
76
+ case "Literal":
77
+ return undefined
78
+ case "Identifier":
79
+ // SoQL has no escape for a backtick inside a quoted name.
80
+ return segment.value === "" || segment.value.includes("`")
81
+ ? `SoQL cannot name the column ${JSON.stringify(segment.value)}`
82
+ : undefined
83
+ case "Parameter":
84
+ return invalidValue(segment.value)
85
+ case "ArrayHelper":
86
+ return segment.value.length === 0
87
+ ? "SoQL IN needs at least one value"
88
+ : segment.value.map(invalidValue).find((problem) => problem !== undefined)
89
+ default:
90
+ return `SoQL has no ${segment._tag}`
91
+ }
92
+ }
93
+
94
+ /** The SoQL text of a fragment. Fails on a column SoQL cannot name or a value it cannot write. */
95
+ export const compile = (fragment: Fragment): Effect.Effect<string, SoqlError> => {
96
+ const problem = fragment.segments.map(invalidSegment).find((message) => message !== undefined)
97
+ return problem === undefined
98
+ ? Effect.succeed(compiler.compile(fragment, false)[0])
99
+ : Effect.fail(new SoqlError({ message: problem }))
100
+ }
101
+
102
+ export const eq = (column: string, value: Value): Fragment => soql`${identifier(column)} = ${value}`
103
+
104
+ export const isIn = (column: string, values: readonly [Value, ...Array<Value>]): Fragment =>
105
+ soql`${identifier(column)} IN ${Statement.arrayHelper(values)}`
106
+
107
+ export const isNotNull = (column: string): Fragment => soql`${identifier(column)} IS NOT NULL`
108
+
109
+ const joinParenthesized =
110
+ (separator: string) =>
111
+ (clauses: readonly [Fragment, ...Array<Fragment>]): Fragment =>
112
+ Statement.join(separator, false)(clauses.map((clause) => soql`(${clause})`))
113
+
114
+ /** `(a) AND (b)`. Each clause keeps its own parentheses, so an `OR` inside one stays inside it. */
115
+ export const and = joinParenthesized(" AND ")
116
+
117
+ /** `(a) OR (b)`. */
118
+ export const or = joinParenthesized(" OR ")
119
+
120
+ export type SortDirection = "ASC" | "DESC"
121
+
122
+ export interface OrderBy {
123
+ readonly column: string
124
+ readonly direction?: SortDirection | undefined
125
+ }
126
+
127
+ /** The clauses of a query over whole rows. */
128
+ export interface Clauses {
129
+ readonly where?: Fragment | undefined
130
+ readonly orderBy?: OrderBy | undefined
131
+ readonly limit?: number | undefined
132
+ }
133
+
134
+ /** `SELECT *` with each clause that is given, in SoQL's order. */
135
+ export const selectAll = ({ where, orderBy, limit }: Clauses): Fragment =>
136
+ Statement.fragment([
137
+ Statement.literal("SELECT *"),
138
+ ...(where === undefined ? [] : [Statement.literal(" WHERE "), ...where.segments]),
139
+ ...(orderBy === undefined
140
+ ? []
141
+ : [
142
+ Statement.literal(" ORDER BY "),
143
+ identifier(orderBy.column),
144
+ Statement.literal(` ${orderBy.direction ?? "ASC"}`)
145
+ ]),
146
+ ...(limit === undefined ? [] : [Statement.literal(" LIMIT "), Statement.parameter(limit)])
147
+ ])
148
+
149
+ /** A half-open window of Socrata floating timestamps, `start <= t < end`. */
150
+ export interface TimestampWindow {
151
+ readonly start: string
152
+ readonly end: string
153
+ }
154
+
155
+ export interface YearMonth {
156
+ readonly year: number
157
+ readonly month: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
158
+ }
159
+
160
+ const monthStart = (year: number, month: number) =>
161
+ `${year.toString().padStart(4, "0")}-${month.toString().padStart(2, "0")}-01T00:00:00`
162
+
163
+ /** Midnight at the start of a calendar date, as a floating timestamp. */
164
+ export const timestamp = (date: IsoDate) => `${date}T00:00:00`
165
+
166
+ /** The calendar month as a window. */
167
+ export const monthWindow = ({ year, month }: YearMonth): TimestampWindow => ({
168
+ start: monthStart(year, month),
169
+ end: month === 12 ? monthStart(year + 1, 1) : monthStart(year, month + 1)
170
+ })
171
+
172
+ /** The days from `start` up to, not including, `end`. Fails when the window is empty. */
173
+ export const dateWindow = (start: IsoDate, end: IsoDate) =>
174
+ start < end
175
+ ? Effect.succeed<TimestampWindow>({ start: timestamp(start), end: timestamp(end) })
176
+ : Effect.fail(new SoqlError({ message: `Date window ${start}..${end} is empty` }))
177
+
178
+ /** `column` inside the window. */
179
+ export const timestampRange = (column: string, window: TimestampWindow): Fragment =>
180
+ soql`${identifier(column)} >= ${window.start} AND ${identifier(column)} < ${window.end}`
181
+
182
+ /** `column` inside the calendar month. */
183
+ export const monthRange = (column: string, month: YearMonth): Fragment =>
184
+ timestampRange(column, monthWindow(month))
185
+
186
+ /** Rows whose separate year and month columns fall in `[start, end]`, both months included. */
187
+ export const yearMonthRange = (
188
+ columns: { readonly year: string; readonly month: string },
189
+ start: YearMonth,
190
+ end: YearMonth
191
+ ) => {
192
+ if (start.year * 12 + start.month > end.year * 12 + end.month) {
193
+ return Effect.fail(new SoqlError({ message: "Year/month range starts after it ends" }))
194
+ }
195
+ const year = identifier(columns.year)
196
+ const month = identifier(columns.month)
197
+ return Effect.succeed(
198
+ soql`(${year} > ${start.year} OR (${year} = ${start.year} AND ${month} >= ${start.month})) AND (${year} < ${end.year} OR (${year} = ${end.year} AND ${month} <= ${end.month}))`
199
+ )
200
+ }
package/src/index.ts CHANGED
@@ -1,73 +1,5 @@
1
- export const packageName = "@nyc-transit-kit/soda3"
2
- export { buildCatalogSearchUrl, catalogSearch } from "./catalog"
3
- export type { FetchImplementation, Soda3ClientConfigShape, Soda3LayerOptions } from "./client"
4
- export {
5
- Soda3ClientConfig,
6
- Soda3HttpLive,
7
- Soda3Live,
8
- soda3ConfigLayer,
9
- soda3FetchLayer,
10
- soda3Layer
11
- } from "./client"
12
- export {
13
- buildExportUrl,
14
- buildQueryUrl,
15
- decodeSoda3CatalogSearchRequest,
16
- decodeSoda3ExportRequest,
17
- decodeSoda3QueryRequest,
18
- defaultSocrataProtocol,
19
- discoveryApiHost,
20
- socrataApiVersion
21
- } from "./endpoints"
22
- export type { Soda3ClientError } from "./errors"
23
- export {
24
- InvalidInputError,
25
- isRetryableProviderError,
26
- isSoda3ClientError,
27
- ProviderContractError,
28
- ProviderHttpError,
29
- RetryExhaustedError,
30
- TimeoutError
31
- } from "./errors"
32
- export { exportResponse } from "./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"
37
- export type {
38
- SocrataTimestampWindow,
39
- SocrataYearMonth,
40
- SoqlFragment,
41
- SoqlParameters,
42
- SoqlSortDirection
43
- } from "./soql"
44
- export {
45
- socrataDateWindow,
46
- socrataMonthWindow,
47
- socrataTimestamp,
48
- soqlAnd,
49
- soqlEq,
50
- soqlIdentifier,
51
- soqlIn,
52
- soqlIsNotNull,
53
- soqlLimit,
54
- soqlMonthWindow,
55
- soqlOrderBy,
56
- soqlParameter,
57
- soqlParameterName,
58
- soqlSelectAll,
59
- soqlTimestampRange,
60
- soqlYearMonthRange
61
- } from "./soql"
62
- export type { RequestPolicyConfig } from "./transport"
63
- export {
64
- contentLengthOf,
65
- fetchHttpClientLayer,
66
- httpFailureInfo,
67
- httpStatusText,
68
- isOkStatus,
69
- redactedUrl,
70
- retryableStatus,
71
- toWebResponse,
72
- withRequestPolicy
73
- } from "./transport"
1
+ export * as Dataset from "./Dataset"
2
+ export * as Discovery from "./Discovery"
3
+ export * as Socrata from "./Socrata"
4
+ export * as Soda3Client from "./Soda3Client"
5
+ export * as Soql from "./Soql"
package/src/catalog.ts DELETED
@@ -1,33 +0,0 @@
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 { Soda3ClientConfig } from "./client"
5
- import { buildCatalogSearchUrl } from "./endpoints"
6
- import {
7
- addHeaders,
8
- decodeCatalogSearchResponse,
9
- decodeJson,
10
- executeRequest,
11
- requestWithPolicy
12
- } from "./internal/transport"
13
-
14
- export { buildCatalogSearchUrl } from "./endpoints"
15
- export type { Soda3CatalogSearchRequestInput }
16
-
17
- export const catalogSearch = Effect.fn("Soda3.catalogSearch")(function* (
18
- input: Soda3CatalogSearchRequestInput
19
- ) {
20
- const config = yield* Soda3ClientConfig
21
- const url = yield* buildCatalogSearchUrl(input)
22
- const request = HttpClientRequest.get(url, {
23
- headers: addHeaders(config)
24
- })
25
-
26
- return yield* requestWithPolicy(
27
- "catalog",
28
- executeRequest("catalog", request).pipe(
29
- Effect.flatMap((response) => decodeJson("catalog", response)),
30
- Effect.flatMap(decodeCatalogSearchResponse)
31
- )
32
- )
33
- })
package/src/client.ts DELETED
@@ -1,44 +0,0 @@
1
- import * as Context from "effect/Context"
2
- import * as Layer from "effect/Layer"
3
- import { type FetchImplementation, fetchHttpClientLayer } from "./transport"
4
-
5
- export { catalogSearch } from "./catalog"
6
- export { exportResponse } from "./export"
7
- export { fetchDatasetMetadata } from "./metadata"
8
- export { countRows, queryAllRows, queryRows } from "./query"
9
- export type { FetchImplementation } from "./transport"
10
-
11
- export interface Soda3ClientConfigShape {
12
- readonly appToken?: string
13
- readonly retryTimes?: number
14
- readonly retryDelayMs?: number
15
- readonly timeoutMs?: number
16
- }
17
-
18
- export interface Soda3LayerOptions extends Soda3ClientConfigShape {
19
- readonly fetch?: FetchImplementation
20
- }
21
-
22
- export class Soda3ClientConfig extends Context.Service<Soda3ClientConfig, Soda3ClientConfigShape>()(
23
- "@nyc-transit-kit/soda3/Soda3ClientConfig"
24
- ) {
25
- static readonly Default = Layer.succeed(Soda3ClientConfig)({
26
- retryTimes: 0
27
- })
28
- }
29
-
30
- export const soda3ConfigLayer = (options: Soda3ClientConfigShape = {}) =>
31
- Layer.succeed(Soda3ClientConfig)({
32
- retryTimes: options.retryTimes ?? 0,
33
- ...(options.appToken === undefined ? {} : { appToken: options.appToken }),
34
- ...(options.retryDelayMs === undefined ? {} : { retryDelayMs: options.retryDelayMs }),
35
- ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs })
36
- })
37
-
38
- export const Soda3HttpLive = fetchHttpClientLayer()
39
- export const soda3FetchLayer = fetchHttpClientLayer
40
-
41
- export const soda3Layer = (options: Soda3LayerOptions = {}) =>
42
- Layer.mergeAll(soda3ConfigLayer(options), soda3FetchLayer(options.fetch))
43
-
44
- export const Soda3Live = soda3Layer()
package/src/endpoints.ts DELETED
@@ -1,77 +0,0 @@
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 { decodeInput } from "./internal/decode"
11
-
12
- export const socrataApiVersion = "v3"
13
- export const defaultSocrataProtocol = "https"
14
- export const discoveryApiHost = "api.us.socrata.com"
15
-
16
- const decodeQueryRequest = decodeInput("query", Soda3QueryRequest)
17
- const decodeExportRequest = decodeInput("export", Soda3ExportRequest)
18
- const decodeCatalogSearchRequest = decodeInput("catalog", Soda3CatalogSearchRequest)
19
-
20
- const viewUrl = (domain: string, datasetId: string, suffix: string) => {
21
- const url = new URL(`${defaultSocrataProtocol}://${domain}`)
22
- url.pathname = `/api/${socrataApiVersion}/views/${datasetId}/${suffix}`
23
- return url
24
- }
25
-
26
- export const buildQueryUrl = Effect.fn("Soda3.buildQueryUrl")(function* (
27
- input: Soda3QueryRequestInput
28
- ) {
29
- const request = yield* decodeQueryRequest(input)
30
- return viewUrl(request.domain, request.datasetId, "query.json")
31
- })
32
-
33
- export const buildExportUrl = Effect.fn("Soda3.buildExportUrl")(function* (
34
- input: Soda3ExportRequestInput
35
- ) {
36
- const request = yield* decodeExportRequest(input)
37
- return viewUrl(request.domain, request.datasetId, `export.${request.format}`)
38
- })
39
-
40
- export const buildCatalogSearchUrl = Effect.fn("Soda3.buildCatalogSearchUrl")(function* (
41
- input: Soda3CatalogSearchRequestInput
42
- ) {
43
- const request = yield* decodeCatalogSearchRequest(input)
44
- const url = new URL(`${defaultSocrataProtocol}://${discoveryApiHost}`)
45
- url.pathname = "/api/catalog/v1"
46
- url.searchParams.set("domains", request.domain)
47
- url.searchParams.set("search_context", request.domain)
48
- url.searchParams.set("only", "dataset")
49
-
50
- if (request.query !== undefined) {
51
- url.searchParams.set("search", request.query)
52
- }
53
- for (const id of request.ids ?? []) {
54
- url.searchParams.append("ids", id)
55
- }
56
- for (const category of request.categories ?? []) {
57
- url.searchParams.append("categories", category)
58
- }
59
- for (const tag of request.tags ?? []) {
60
- url.searchParams.append("tags", tag)
61
- }
62
- if (request.order !== undefined) {
63
- url.searchParams.set("order", request.order)
64
- }
65
- if (request.limit !== undefined) {
66
- url.searchParams.set("limit", request.limit.toString())
67
- }
68
- if (request.offset !== undefined) {
69
- url.searchParams.set("offset", request.offset.toString())
70
- }
71
-
72
- return url
73
- })
74
-
75
- export const decodeSoda3QueryRequest = decodeQueryRequest
76
- export const decodeSoda3ExportRequest = decodeExportRequest
77
- export const decodeSoda3CatalogSearchRequest = decodeCatalogSearchRequest
package/src/errors.ts DELETED
@@ -1,58 +0,0 @@
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 isSoda3ClientError = (value: unknown): value is Soda3ClientError =>
51
- value instanceof InvalidInputError ||
52
- value instanceof ProviderHttpError ||
53
- value instanceof ProviderContractError ||
54
- value instanceof TimeoutError ||
55
- value instanceof RetryExhaustedError
56
-
57
- export const isRetryableProviderError = (error: Soda3ClientError): error is ProviderHttpError =>
58
- error._tag === "ProviderHttpError" && error.retryable
package/src/export.ts DELETED
@@ -1,46 +0,0 @@
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 { Soda3ClientConfig } from "./client"
5
- import { buildExportUrl, decodeSoda3ExportRequest } from "./endpoints"
6
- import { InvalidInputError } from "./errors"
7
- import {
8
- addHeaders,
9
- executeRequest,
10
- rangeHeader,
11
- requestWithPolicy,
12
- toExportBody,
13
- toWebResponse
14
- } from "./internal/transport"
15
-
16
- export type { Soda3ExportRequestInput }
17
-
18
- export const exportResponse = Effect.fn("Soda3.exportResponse")(function* (
19
- input: Soda3ExportRequestInput
20
- ) {
21
- const config = yield* Soda3ClientConfig
22
- const exportRequest = yield* decodeSoda3ExportRequest(input)
23
- const url = yield* buildExportUrl(exportRequest)
24
- const headers = addHeaders(config)
25
- const range = rangeHeader(exportRequest)
26
-
27
- const request = yield* HttpClientRequest.post(url, {
28
- headers: {
29
- ...headers,
30
- range
31
- }
32
- }).pipe(
33
- HttpClientRequest.bodyJson(toExportBody(exportRequest)),
34
- Effect.mapError(() =>
35
- InvalidInputError.make({
36
- operation: "export",
37
- message: "SODA3 export body was not JSON serializable"
38
- })
39
- )
40
- )
41
-
42
- return yield* requestWithPolicy(
43
- "export",
44
- executeRequest("export", request).pipe(Effect.flatMap(toWebResponse))
45
- )
46
- })
@@ -1,12 +0,0 @@
1
- import * as Effect from "effect/Effect"
2
- import * as Schema from "effect/Schema"
3
- import { InvalidInputError } from "../errors"
4
-
5
- export const decodeInput =
6
- <S extends Schema.Top>(operation: string, schema: S) =>
7
- (input: unknown) =>
8
- Schema.decodeUnknownEffect(schema)(input).pipe(
9
- Effect.catchTag("SchemaError", (error) =>
10
- Effect.fail(InvalidInputError.make({ operation, message: error.message }))
11
- )
12
- )