@nyc-transit-kit/soda3 0.1.3 → 0.2.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,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.2.0",
4
+ "license": "MIT",
4
5
  "type": "module",
5
6
  "sideEffects": false,
6
7
  "exports": {
@@ -10,8 +11,10 @@
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
- "./soql": "./src/soql.ts"
16
+ "./soql": "./src/soql.ts",
17
+ "./transport": "./src/transport.ts"
15
18
  },
16
19
  "types": "./src/index.ts",
17
20
  "files": [
@@ -24,13 +27,12 @@
24
27
  "test": "bun test ./test"
25
28
  },
26
29
  "dependencies": {
27
- "@nyc-transit-kit/contracts": "0.1.3",
30
+ "@nyc-transit-kit/contracts": "0.2.0",
28
31
  "effect": "4.0.0-beta.92"
29
32
  },
30
33
  "publishConfig": {
31
34
  "access": "public"
32
35
  },
33
- "license": "MIT",
34
36
  "repository": {
35
37
  "type": "git",
36
38
  "url": "git+https://github.com/mannyc2/nyc-transit-kit.git",
package/src/catalog.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  import type { Soda3CatalogSearchRequestInput } from "@nyc-transit-kit/contracts/soda3"
2
2
  import * as Effect from "effect/Effect"
3
3
  import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
4
+ import { Soda3ClientConfig } from "./client"
4
5
  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"
6
+ import {
7
+ addHeaders,
8
+ decodeCatalogSearchResponse,
9
+ decodeJson,
10
+ executeRequest,
11
+ requestWithPolicy
12
+ } from "./internal/transport"
8
13
 
9
14
  export { buildCatalogSearchUrl } from "./endpoints"
10
15
  export type { Soda3CatalogSearchRequestInput }
package/src/client.ts CHANGED
@@ -1,12 +1,44 @@
1
+ import * as Context from "effect/Context"
2
+ import * as Layer from "effect/Layer"
3
+ import { type FetchImplementation, fetchHttpClientLayer } from "./transport"
4
+
1
5
  export { catalogSearch } from "./catalog"
2
6
  export { exportResponse } from "./export"
3
- export { Soda3HttpLive } from "./internal/http"
4
- export type { Soda3ClientConfigShape, Soda3LayerOptions } from "./internal/services"
5
- export {
6
- Soda3ClientConfig,
7
- Soda3Live,
8
- soda3ConfigLayer,
9
- soda3FetchLayer,
10
- soda3Layer
11
- } from "./internal/services"
12
- export { queryRows } from "./query"
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 CHANGED
@@ -7,48 +7,15 @@ import {
7
7
  type Soda3QueryRequestInput
8
8
  } from "@nyc-transit-kit/contracts/soda3"
9
9
  import * as Effect from "effect/Effect"
10
- import * as Schema from "effect/Schema"
11
- import { InvalidInputError } from "./errors"
10
+ import { decodeInput } from "./internal/decode"
12
11
 
13
12
  export const socrataApiVersion = "v3"
14
13
  export const defaultSocrataProtocol = "https"
15
14
  export const discoveryApiHost = "api.us.socrata.com"
16
15
 
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
- )
16
+ const decodeQueryRequest = decodeInput("query", Soda3QueryRequest)
17
+ const decodeExportRequest = decodeInput("export", Soda3ExportRequest)
18
+ const decodeCatalogSearchRequest = decodeInput("catalog", Soda3CatalogSearchRequest)
52
19
 
53
20
  const viewUrl = (domain: string, datasetId: string, suffix: string) => {
54
21
  const url = new URL(`${defaultSocrataProtocol}://${domain}`)
@@ -83,6 +50,18 @@ export const buildCatalogSearchUrl = Effect.fn("Soda3.buildCatalogSearchUrl")(fu
83
50
  if (request.query !== undefined) {
84
51
  url.searchParams.set("search", request.query)
85
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
+ }
86
65
  if (request.limit !== undefined) {
87
66
  url.searchParams.set("limit", request.limit.toString())
88
67
  }
package/src/export.ts CHANGED
@@ -1,12 +1,17 @@
1
1
  import type { Soda3ExportRequestInput } from "@nyc-transit-kit/contracts/soda3"
2
2
  import * as Effect from "effect/Effect"
3
3
  import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
4
+ import { Soda3ClientConfig } from "./client"
4
5
  import { buildExportUrl, decodeSoda3ExportRequest } from "./endpoints"
5
6
  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"
7
+ import {
8
+ addHeaders,
9
+ executeRequest,
10
+ rangeHeader,
11
+ requestWithPolicy,
12
+ toExportBody,
13
+ toWebResponse
14
+ } from "./internal/transport"
10
15
 
11
16
  export type { Soda3ExportRequestInput }
12
17
 
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,
@@ -56,3 +59,15 @@ export {
56
59
  soqlTimestampRange,
57
60
  soqlYearMonthRange
58
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"
@@ -0,0 +1,12 @@
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
+ )
@@ -1,15 +1,39 @@
1
+ import {
2
+ Soda3CatalogSearchResponse,
3
+ type Soda3ExportRequest,
4
+ type Soda3QueryRequest,
5
+ Soda3QueryResponse,
6
+ Soda3Row
7
+ } from "@nyc-transit-kit/contracts/soda3"
1
8
  import * as Effect from "effect/Effect"
9
+ import * as Schema from "effect/Schema"
2
10
  import * as HttpClient from "effect/unstable/http/HttpClient"
11
+ import type * as HttpClientError from "effect/unstable/http/HttpClientError"
3
12
  import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
13
+ import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
14
+ import { Soda3ClientConfig, type Soda3ClientConfigShape } from "../client"
4
15
  import type { Soda3ClientError } from "../errors"
5
16
  import {
6
17
  isRetryableProviderError,
18
+ ProviderContractError,
7
19
  ProviderHttpError,
8
20
  RetryExhaustedError,
9
21
  TimeoutError
10
22
  } from "../errors"
11
- import { mapHttpClientError, retryableStatus } from "./http"
12
- import { Soda3ClientConfig, type Soda3ClientConfigShape } from "./services"
23
+ import {
24
+ httpFailureInfo,
25
+ httpStatusText,
26
+ isOkStatus,
27
+ retryableStatus,
28
+ withRequestPolicy
29
+ } from "../transport"
30
+
31
+ export { retryableStatus, toWebResponse } from "../transport"
32
+
33
+ const providerRows = Schema.Array(Soda3Row)
34
+
35
+ const decodeUnknown = <S extends Schema.Top>(schema: S, input: unknown) =>
36
+ Schema.decodeUnknownEffect(schema)(input)
13
37
 
14
38
  export const addHeaders = (config: Soda3ClientConfigShape) => ({
15
39
  accept: "application/json",
@@ -18,7 +42,83 @@ export const addHeaders = (config: Soda3ClientConfigShape) => ({
18
42
  : {})
19
43
  })
20
44
 
21
- export { retryableStatus }
45
+ export const toQueryBody = (request: Soda3QueryRequest) => ({
46
+ query: request.query,
47
+ page: request.page,
48
+ parameters: request.parameters,
49
+ timeout: request.timeout,
50
+ includeSystem: request.includeSystem,
51
+ includeSynthetic: request.includeSynthetic,
52
+ orderingSpecifier: request.orderingSpecifier
53
+ })
54
+
55
+ export const toExportBody = (request: Soda3ExportRequest) => ({
56
+ query: request.query,
57
+ parameters: request.parameters,
58
+ timeout: request.timeout,
59
+ serializationOptions: request.serializationOptions
60
+ })
61
+
62
+ export const rangeHeader = (request: Soda3ExportRequest) =>
63
+ request.range === undefined
64
+ ? undefined
65
+ : `bytes=${request.range.start}-${request.range.end ?? ""}`
66
+
67
+ export const mapHttpClientError = (operation: string, error: HttpClientError.HttpClientError) => {
68
+ const info = httpFailureInfo(error)
69
+
70
+ return ProviderHttpError.make({
71
+ operation,
72
+ status: info.status,
73
+ statusText: info.statusText,
74
+ retryable: info.status === 0 || retryableStatus(info.status)
75
+ })
76
+ }
77
+
78
+ export const decodeJson = (operation: string, response: HttpClientResponse.HttpClientResponse) =>
79
+ response.json.pipe(
80
+ Effect.catchTag("HttpClientError", () =>
81
+ Effect.fail(
82
+ ProviderContractError.make({
83
+ operation,
84
+ message: "Provider response was not valid JSON"
85
+ })
86
+ )
87
+ )
88
+ )
89
+
90
+ export const decodeQueryResponse = (input: unknown) =>
91
+ decodeUnknown(Soda3QueryResponse, input).pipe(
92
+ Effect.catchTag("SchemaError", (error) =>
93
+ decodeUnknown(providerRows, input).pipe(
94
+ Effect.map((rows) =>
95
+ Soda3QueryResponse.make({
96
+ rows
97
+ })
98
+ ),
99
+ Effect.catchTag("SchemaError", () =>
100
+ Effect.fail(
101
+ ProviderContractError.make({
102
+ operation: "query",
103
+ message: error.message
104
+ })
105
+ )
106
+ )
107
+ )
108
+ )
109
+ )
110
+
111
+ export const decodeCatalogSearchResponse = (input: unknown) =>
112
+ decodeUnknown(Soda3CatalogSearchResponse, input).pipe(
113
+ Effect.catchTag("SchemaError", (error) =>
114
+ Effect.fail(
115
+ ProviderContractError.make({
116
+ operation: "catalog",
117
+ message: error.message
118
+ })
119
+ )
120
+ )
121
+ )
22
122
 
23
123
  export const requestWithPolicy = <A>(
24
124
  operation: string,
@@ -26,55 +126,29 @@ export const requestWithPolicy = <A>(
26
126
  ) =>
27
127
  Effect.gen(function* () {
28
128
  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
129
+ return yield* withRequestPolicy(config, effect, {
130
+ isRetryable: isRetryableProviderError,
131
+ onExhausted: (attempts, error) =>
132
+ RetryExhaustedError.make({
133
+ operation,
134
+ attempts,
135
+ message: `Retry attempts exhausted after provider status ${error.status}`
136
+ }),
137
+ onTimeout: (timeoutMs) =>
138
+ TimeoutError.make({
139
+ operation,
140
+ timeoutMs
141
+ })
142
+ })
67
143
  })
68
144
 
69
- const httpStatusText = (status: number) => `HTTP ${status}`
70
-
71
145
  export const executeRequest = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
72
146
  Effect.gen(function* () {
73
147
  const response = yield* HttpClient.execute(request).pipe(
74
148
  Effect.mapError((error) => mapHttpClientError(operation, error))
75
149
  )
76
150
 
77
- if (response.status < 200 || response.status >= 300) {
151
+ if (!isOkStatus(response.status)) {
78
152
  return yield* ProviderHttpError.make({
79
153
  operation,
80
154
  status: response.status,
@@ -0,0 +1,37 @@
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 { catalogSearch } from "./catalog"
8
+ import { ProviderContractError } from "./errors"
9
+ import { decodeInput } from "./internal/decode"
10
+
11
+ export type { Soda3DatasetMetadataRequestInput }
12
+
13
+ const decodeDatasetMetadataRequest = decodeInput("metadata", Soda3DatasetMetadataRequest)
14
+
15
+ const matchingResult = (results: ReadonlyArray<Soda3CatalogResultFragment>, datasetId: string) =>
16
+ results.find((result) => String(result.resource.id) === datasetId)
17
+
18
+ export const fetchDatasetMetadata = Effect.fn("Soda3.fetchDatasetMetadata")(function* (
19
+ input: Soda3DatasetMetadataRequestInput
20
+ ) {
21
+ const request = yield* decodeDatasetMetadataRequest(input)
22
+ const response = yield* catalogSearch({
23
+ domain: request.domain,
24
+ ids: [request.datasetId],
25
+ limit: 1
26
+ })
27
+ const result = matchingResult(response.results, String(request.datasetId))
28
+
29
+ if (result === undefined) {
30
+ return yield* ProviderContractError.make({
31
+ operation: "metadata",
32
+ message: `Discovery metadata response did not include dataset ${request.datasetId}`
33
+ })
34
+ }
35
+
36
+ return result
37
+ })
package/src/query.ts CHANGED
@@ -1,15 +1,62 @@
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"
3
7
  import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
8
+ import { Soda3ClientConfig } from "./client"
4
9
  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
+ import { InvalidInputError, ProviderContractError } from "./errors"
11
+ import { decodeInput } from "./internal/decode"
12
+ import {
13
+ addHeaders,
14
+ decodeJson,
15
+ decodeQueryResponse,
16
+ executeRequest,
17
+ requestWithPolicy,
18
+ toQueryBody
19
+ } from "./internal/transport"
10
20
 
11
21
  export type { Soda3QueryRequestInput }
12
22
 
23
+ export type Soda3QueryAllRowsInput = Omit<Soda3QueryRequestInput, "page"> & {
24
+ readonly pageSize?: number
25
+ readonly maxRows?: number
26
+ }
27
+
28
+ export type Soda3CountRowsInput = {
29
+ readonly domain: string
30
+ readonly datasetId: string
31
+ readonly where?: string
32
+ readonly parameters?: Readonly<Record<string, unknown>>
33
+ }
34
+
35
+ const decodeHelperPositiveInteger = (name: string, value: number) =>
36
+ decodeInput(
37
+ "query",
38
+ PositiveInteger
39
+ )(value).pipe(
40
+ Effect.mapError((error) =>
41
+ InvalidInputError.make({
42
+ operation: "query",
43
+ message: `Invalid ${name}: ${error.message}`
44
+ })
45
+ )
46
+ )
47
+
48
+ const decodeOptionalHelperPositiveInteger = (name: string, value: number | undefined) =>
49
+ value === undefined ? Effect.void : decodeHelperPositiveInteger(name, value)
50
+
51
+ const decodeCountValue = (value: unknown) => {
52
+ const parsed =
53
+ typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN
54
+
55
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined
56
+ }
57
+
58
+ const countColumnName = "count"
59
+
13
60
  export const queryRows = Effect.fn("Soda3.queryRows")(function* (input: Soda3QueryRequestInput) {
14
61
  const config = yield* Soda3ClientConfig
15
62
  const queryRequest = yield* decodeSoda3QueryRequest(input)
@@ -34,3 +81,58 @@ export const queryRows = Effect.fn("Soda3.queryRows")(function* (input: Soda3Que
34
81
  )
35
82
  )
36
83
  })
84
+
85
+ export const queryAllRows = Effect.fn("Soda3.queryAllRows")(function* (
86
+ input: Soda3QueryAllRowsInput
87
+ ) {
88
+ const { pageSize: rawPageSize = 1000, maxRows: rawMaxRows, ...queryInput } = input
89
+ const pageSize = yield* decodeHelperPositiveInteger("pageSize", rawPageSize)
90
+ const maxRows = yield* decodeOptionalHelperPositiveInteger("maxRows", rawMaxRows)
91
+ const rows: Array<Soda3Row> = []
92
+ let pageNumber = 1
93
+
94
+ while (true) {
95
+ const response = yield* queryRows({
96
+ ...queryInput,
97
+ page: {
98
+ pageNumber,
99
+ pageSize
100
+ }
101
+ })
102
+ rows.push(...response.rows)
103
+
104
+ if (maxRows !== undefined && rows.length > maxRows) {
105
+ return yield* InvalidInputError.make({
106
+ operation: "query",
107
+ message: `SODA3 query exceeded maxRows=${maxRows}; narrow the query or raise maxRows`
108
+ })
109
+ }
110
+
111
+ if (response.rows.length < pageSize) {
112
+ return rows
113
+ }
114
+
115
+ pageNumber += 1
116
+ }
117
+ })
118
+
119
+ export const countRows = Effect.fn("Soda3.countRows")(function* (input: Soda3CountRowsInput) {
120
+ const query = `SELECT count(*)${input.where === undefined ? "" : ` WHERE ${input.where}`}`
121
+ const response = yield* queryRows({
122
+ domain: input.domain,
123
+ datasetId: input.datasetId,
124
+ query,
125
+ ...(input.parameters === undefined ? {} : { parameters: input.parameters }),
126
+ includeSynthetic: false
127
+ })
128
+ const count = decodeCountValue(response.rows[0]?.[countColumnName])
129
+
130
+ if (count === undefined) {
131
+ return yield* ProviderContractError.make({
132
+ operation: "query",
133
+ message: "SODA3 count(*) response did not include a numeric count"
134
+ })
135
+ }
136
+
137
+ return count
138
+ })