@seta-labs/geo 0.7.0 → 0.8.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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @seta-labs/geo
2
2
 
3
+ ## 0.8.0
4
+
5
+ - **Breaking:** `LookupCache` becomes `CachePolicy`, the cache options every
6
+ Seta service and SDK shares: `refresh` is now `noCache`, like the header
7
+ directive; `noStore`, `maxAge` and `onlyIfCached` keep their names. The type
8
+ and its header writer come from `@seta-labs/nestjs-cache-control/policy`,
9
+ bundled into the SDK: nothing more to install.
10
+ - Each client paces its calls (`pacing`): at most 10 in flight, a `429` or
11
+ `503` sent again up to 4 times after its `Retry-After` (up to a minute;
12
+ 1-8 s backoff without it), the whole queue waiting meanwhile. `pacing:
13
+ { concurrency, retries }` tunes it, `pacing: false` turns it off; two queues
14
+ are two clients.
15
+ - `timeoutMs` (default 30 s) bounds each attempt, not the wait in the queue.
16
+ - `ApiError.code`: Geo's error code (`CADASTRE_BLOCKED`, `NOT_CACHED`, ...)
17
+ read from the body.
18
+
3
19
  ## 0.7.0
4
20
 
5
21
  - `reverseGeocode(input)` answers from a chain of sources: the ANNCSU register
package/README.md CHANGED
@@ -40,9 +40,20 @@ Do not use `$fetch` as `fetch`: it returns the parsed body instead of a Response
40
40
 
41
41
  All calls return `Promise<T>` and accept optional `{ signal }` as the last
42
42
  argument. An optional `fetch: (request: Request) => Promise<Response>` allows
43
- consumer-owned instrumentation or transport adapters. There is no scheduler,
44
- queue, rate limiter or automatic retry, including for failed writes. Consumers
45
- retain their own concurrency and retry policy.
43
+ consumer-owned instrumentation or transport adapters.
44
+
45
+ Each client paces its calls: at most 10 in flight, and a `429` or `503` that
46
+ says when to come back (`Retry-After` up to a minute; 1 s, 2 s, 4 s, 8 s
47
+ without it) is sent again up to 4 times while the client's whole queue waits.
48
+ A refusal asking for a longer wait, every other status and network errors
49
+ reach the caller after one attempt. Each attempt has `timeoutMs` (default
50
+ 30 s), not counting the wait in the queue. Two queues are two clients:
51
+
52
+ ```ts
53
+ const geo = createGeoClient({ baseUrl, apiKey }); // lookups, territory
54
+ const cadastre = createGeoClient({ baseUrl, apiKey, pacing: { concurrency: 4 } }); // the map, apart
55
+ const panel = createGeoClient({ baseUrl: '/api', pacing: false }); // every call at once, once
56
+ ```
46
57
 
47
58
  | Method | Input |
48
59
  | ---------------------------------------- | -------------------------------------- |
@@ -66,14 +77,24 @@ validation of received data. `include` may be omitted and defaults to `best`
66
77
  on the service.
67
78
 
68
79
  The four lookups (`geocode`, `geocodeBatch`, `reverseGeocode`,
69
- `cadastreParcel`) take `cache` in their options, sent as `Cache-Control`:
70
- `{ refresh: true }` asks the provider again and stores the new answer,
71
- `{ refresh: true, noStore: true }` bypasses the store entirely, `{ maxAge }`
72
- asks again past that many seconds, `{ onlyIfCached: true }` never asks the
73
- provider (504 `NOT_CACHED`, or `status: 'NOT_CACHED'` for a batch item).
80
+ `cadastreParcel`) take `cache` in their options: a `CachePolicy`, the shape
81
+ every Seta service and SDK shares (`@seta-labs/nestjs-cache-control/policy`,
82
+ bundled here), sent as `Cache-Control`. `{ noCache: true }` asks the provider
83
+ again and stores the new answer, `{ noCache: true, noStore: true }` bypasses
84
+ the store entirely, `{ maxAge }` asks again past that many seconds,
85
+ `{ onlyIfCached: true }` never asks the provider (504 `NOT_CACHED`, or
86
+ `status: 'NOT_CACHED'` for a batch item). Without `cache` the store answers
87
+ when it can and the provider otherwise. The other calls read Geo's own data
88
+ and take only `signal`.
89
+
90
+ ```ts
91
+ await geo.reverseGeocode({ lat, lng }, { cache: { onlyIfCached: true } });
92
+ await geo.reverseGeocode({ lat, lng }, { cache: jobCachePolicy() }); // the job's, at each call
93
+ ```
74
94
 
75
95
  HTTP errors are `ApiError`; their body is JSON when parseable, otherwise text,
76
- or `undefined` for an empty response. A `503` from `cadastreParcel` with
96
+ or `undefined` for an empty response, and `code` is Geo's error code when the
97
+ body has one. A `503` from `cadastreParcel` with
77
98
  `code: 'CADASTRE_BLOCKED'` means the cadastral map is refusing calls: its
78
99
  `Retry-After` header (and `retryAfterSeconds` in the body) says when to ask
79
100
  again. Network failures, aborts and JSON parsing
package/dist/index.d.ts CHANGED
@@ -4,6 +4,8 @@ declare class ApiError extends Error {
4
4
  readonly status: number;
5
5
  readonly headers: Headers;
6
6
  readonly requestId: string | undefined;
7
+ /** Geo's error `code` (`CADASTRE_BLOCKED`, `NOT_CACHED`, ...), when the body has one. */
8
+ readonly code: string | undefined;
7
9
  constructor(response: Response, body: unknown);
8
10
  }
9
11
 
@@ -1294,6 +1296,31 @@ type TerritoryQuotazione = ApiSchemas['TerritoryQuotazioneDto'];
1294
1296
  type TerritoryCompravenditePage = ApiSchemas['TerritoryCompravenditePageDto'];
1295
1297
  type TerritoryCompravendita = ApiSchemas['TerritoryCompravenditaDto'];
1296
1298
 
1299
+ /**
1300
+ * The request directives of RFC 9111 a lookup honours; any other directive is
1301
+ * ignored. One shape for both sides: the server reads it from the header
1302
+ * (`parseCacheControl`, every flag set), a client writes it
1303
+ * (`formatCacheControl`, an absent flag is false).
1304
+ */
1305
+ interface CachePolicy {
1306
+ /** `no-cache`: never answer from the store without asking the provider. */
1307
+ noCache?: boolean;
1308
+ /** `no-store`: never write the provider's answer to the store. */
1309
+ noStore?: boolean;
1310
+ /** `only-if-cached`: never ask the provider; without a usable row, refuse. */
1311
+ onlyIfCached?: boolean;
1312
+ /** `max-age=N`: a stored row older than N seconds is not usable. */
1313
+ maxAge?: number;
1314
+ }
1315
+
1316
+ /** How a client paces its calls to Geo; each client has its own queue. */
1317
+ interface PacingOptions {
1318
+ /** Requests in flight at once; default 10. */
1319
+ concurrency?: number;
1320
+ /** New attempts after a 429 or 503 that says when to come back; default 4 (5 attempts in all). */
1321
+ retries?: number;
1322
+ }
1323
+
1297
1324
  type TerritoryComuniQuery = NonNullable<ApiPaths['/territory/comuni']['get']['parameters']['query']>;
1298
1325
  type TerritoryQuotazioniQuery = NonNullable<ApiPaths['/territory/quotazioni']['get']['parameters']['query']>;
1299
1326
  type TerritoryCompravenditeQuery = NonNullable<ApiPaths['/territory/compravendite']['get']['parameters']['query']>;
@@ -1302,21 +1329,12 @@ interface RequestOptions {
1302
1329
  signal?: AbortSignal;
1303
1330
  }
1304
1331
  /**
1305
- * How a lookup may use seta-geo's store, sent as `Cache-Control` (RFC 9111).
1306
- * Without it the store answers when it can and the provider otherwise.
1332
+ * The options of a lookup, the four calls Geo answers from its store:
1333
+ * `cache` is sent as `Cache-Control` (RFC 9111). Without it Geo answers
1334
+ * from the store when it can and asks the provider otherwise.
1307
1335
  */
1308
- interface LookupCache {
1309
- /** `no-cache`: ask the provider even with a stored answer, and store the new one. */
1310
- refresh?: boolean;
1311
- /** `no-store`: never write the provider's answer (with `refresh`: a pure bypass). */
1312
- noStore?: boolean;
1313
- /** `max-age`: a stored answer older than this many seconds is asked again. */
1314
- maxAge?: number;
1315
- /** `only-if-cached`: never ask the provider; 504 `NOT_CACHED` without a stored answer. */
1316
- onlyIfCached?: boolean;
1317
- }
1318
1336
  interface LookupOptions extends RequestOptions {
1319
- cache?: LookupCache;
1337
+ cache?: CachePolicy;
1320
1338
  }
1321
1339
  interface GeoClientOptions {
1322
1340
  /** Complete API root, including /api. Relative roots are supported in browsers. */
@@ -1325,6 +1343,10 @@ interface GeoClientOptions {
1325
1343
  apiKey?: string;
1326
1344
  /** A real Fetch implementation returning a Response, not a parsed-body $fetch. */
1327
1345
  fetch?: (request: Request) => Promise<Response>;
1346
+ /** Each attempt's deadline, not counting the wait in the queue; default 30 s. */
1347
+ timeoutMs?: number;
1348
+ /** The client's queue and its retries on 429/503; `false` sends every call at once, once. */
1349
+ pacing?: false | PacingOptions;
1328
1350
  }
1329
1351
  interface TerritoryClient {
1330
1352
  status(options?: RequestOptions): Promise<TerritoryStatus>;
@@ -1350,4 +1372,4 @@ interface GeoClient {
1350
1372
  declare function createGeoClient(options: GeoClientOptions): GeoClient;
1351
1373
 
1352
1374
  export { ApiError, createGeoClient };
1353
- export type { CadastreParcelRequest, CadastreParcelResult, CadastreParcelRow, CadastreParcelsPage, CadastreParcelsQuery, GeoAdminArea, GeoClient, GeoClientOptions, GeoStreetMatch, GeocodeBatchRequest, GeocodeBatchResponse, GeocodeRequest, GeocodeResult, LookupCache, LookupOptions, RequestOptions, ReverseGeocodeRequest, ReverseGeocodeResult, TerritoryClient, TerritoryCompravendita, TerritoryCompravenditePage, TerritoryCompravenditeQuery, TerritoryComune, TerritoryComuneDetail, TerritoryComuneRiga, TerritoryComuniPage, TerritoryComuniQuery, TerritoryDatasets, TerritoryOmi, TerritoryPointResult, TerritoryQuotazione, TerritoryQuotazioniPage, TerritoryQuotazioniQuery, TerritoryResolveRequest, TerritoryResolveResponse, TerritoryStatus, TerritoryTerritorio };
1375
+ export type { CachePolicy, CadastreParcelRequest, CadastreParcelResult, CadastreParcelRow, CadastreParcelsPage, CadastreParcelsQuery, GeoAdminArea, GeoClient, GeoClientOptions, GeoStreetMatch, GeocodeBatchRequest, GeocodeBatchResponse, GeocodeRequest, GeocodeResult, LookupOptions, PacingOptions, RequestOptions, ReverseGeocodeRequest, ReverseGeocodeResult, TerritoryClient, TerritoryCompravendita, TerritoryCompravenditePage, TerritoryCompravenditeQuery, TerritoryComune, TerritoryComuneDetail, TerritoryComuneRiga, TerritoryComuniPage, TerritoryComuniQuery, TerritoryDatasets, TerritoryOmi, TerritoryPointResult, TerritoryQuotazione, TerritoryQuotazioniPage, TerritoryQuotazioniQuery, TerritoryResolveRequest, TerritoryResolveResponse, TerritoryStatus, TerritoryTerritorio };
package/dist/index.js CHANGED
@@ -7,11 +7,14 @@ var ApiError = class extends Error {
7
7
  this.status = response.status;
8
8
  this.headers = new Headers(response.headers);
9
9
  this.requestId = response.headers.get("x-request-id") ?? void 0;
10
+ this.code = codeOf(body);
10
11
  }
11
12
  body;
12
13
  status;
13
14
  headers;
14
15
  requestId;
16
+ /** Geo's error `code` (`CADASTRE_BLOCKED`, `NOT_CACHED`, ...), when the body has one. */
17
+ code;
15
18
  };
16
19
  async function readErrorBody(response) {
17
20
  const text = await response.text();
@@ -22,9 +25,126 @@ async function readErrorBody(response) {
22
25
  return text;
23
26
  }
24
27
  }
28
+ function codeOf(body) {
29
+ if (typeof body !== "object" || body === null || !("code" in body)) return void 0;
30
+ return typeof body.code === "string" ? body.code : void 0;
31
+ }
32
+
33
+ // ../../node_modules/.pnpm/@seta-labs+nestjs-cache-control@0.2.1_@nestjs+common@12.1.2_class-transformer@0.5.1_cla_d70e55208c987ac6b22f0eff0ce5869e/node_modules/@seta-labs/nestjs-cache-control/dist/cache-policy.util.js
34
+ var MAX_DELTA_SECONDS = 2147483648;
35
+ function formatCacheControl(policy) {
36
+ if (!policy)
37
+ return void 0;
38
+ const maxAge = policy.maxAge;
39
+ const directives = [
40
+ policy.noCache ? "no-cache" : void 0,
41
+ policy.noStore ? "no-store" : void 0,
42
+ maxAge !== void 0 && Number.isFinite(maxAge) ? `max-age=${Math.min(Math.max(0, Math.floor(maxAge)), MAX_DELTA_SECONDS)}` : void 0,
43
+ policy.onlyIfCached ? "only-if-cached" : void 0
44
+ ].filter((directive) => directive !== void 0);
45
+ return directives.length > 0 ? directives.join(", ") : void 0;
46
+ }
25
47
 
26
48
  // src/client.ts
27
49
  import createClient from "openapi-fetch";
50
+
51
+ // src/pacing.ts
52
+ var DEFAULT_CONCURRENCY = 10;
53
+ var DEFAULT_RETRIES = 4;
54
+ var RETRY_STATUSES = /* @__PURE__ */ new Set([429, 503]);
55
+ var BACKOFF_MS = 1e3;
56
+ var MAX_WAIT_MS = 6e4;
57
+ function pacedFetch(fetch, options = {}) {
58
+ const run = limiter(options.concurrency ?? DEFAULT_CONCURRENCY);
59
+ const retries = options.retries ?? DEFAULT_RETRIES;
60
+ let pausedUntil = 0;
61
+ const send = async (request, attempt) => {
62
+ while (pausedUntil > Date.now()) await sleepUntil(pausedUntil, request.signal);
63
+ const response = await fetch(request.clone());
64
+ const waitMs = attempt < retries ? retryDelay(response, attempt) : void 0;
65
+ if (waitMs !== void 0) pausedUntil = Math.max(pausedUntil, Date.now() + waitMs);
66
+ return { response, waitMs };
67
+ };
68
+ return async (request) => {
69
+ for (let attempt = 0; ; attempt++) {
70
+ const { response, waitMs } = await run(request.signal, () => send(request, attempt));
71
+ if (waitMs === void 0) return response;
72
+ await response.body?.cancel();
73
+ }
74
+ };
75
+ }
76
+ function timedFetch(fetch, timeoutMs) {
77
+ return (request) => fetch(
78
+ new Request(request, {
79
+ signal: AbortSignal.any([request.signal, AbortSignal.timeout(timeoutMs)])
80
+ })
81
+ );
82
+ }
83
+ function retryDelay(response, attempt) {
84
+ if (!RETRY_STATUSES.has(response.status)) return void 0;
85
+ const header = response.headers.get("retry-after");
86
+ const waitMs = header === null ? BACKOFF_MS * 2 ** attempt : retryAfterMs(header);
87
+ return waitMs !== void 0 && waitMs <= MAX_WAIT_MS ? waitMs : void 0;
88
+ }
89
+ function retryAfterMs(header) {
90
+ const value = header.trim();
91
+ if (/^\d+$/.test(value)) return Number(value) * 1e3;
92
+ const date = Date.parse(value);
93
+ return Number.isNaN(date) ? void 0 : Math.max(0, date - Date.now());
94
+ }
95
+ function limiter(concurrency) {
96
+ let active = 0;
97
+ const waiting = [];
98
+ const acquire = (signal) => {
99
+ signal.throwIfAborted();
100
+ if (active < concurrency) {
101
+ active++;
102
+ return Promise.resolve();
103
+ }
104
+ return new Promise((resolve, reject) => {
105
+ const start = () => {
106
+ signal.removeEventListener("abort", abort);
107
+ active++;
108
+ resolve();
109
+ };
110
+ const abort = () => {
111
+ waiting.splice(waiting.indexOf(start), 1);
112
+ reject(signal.reason);
113
+ };
114
+ waiting.push(start);
115
+ signal.addEventListener("abort", abort, { once: true });
116
+ });
117
+ };
118
+ return async (signal, task) => {
119
+ await acquire(signal);
120
+ try {
121
+ return await task();
122
+ } finally {
123
+ active--;
124
+ waiting.shift()?.();
125
+ }
126
+ };
127
+ }
128
+ function sleepUntil(time, signal) {
129
+ const delay = time - Date.now();
130
+ if (delay <= 0) return Promise.resolve();
131
+ return new Promise((resolve, reject) => {
132
+ signal.throwIfAborted();
133
+ const timer = setTimeout(done, delay);
134
+ signal.addEventListener("abort", abort, { once: true });
135
+ function done() {
136
+ signal.removeEventListener("abort", abort);
137
+ resolve();
138
+ }
139
+ function abort() {
140
+ clearTimeout(timer);
141
+ reject(signal.reason);
142
+ }
143
+ });
144
+ }
145
+
146
+ // src/client.ts
147
+ var DEFAULT_TIMEOUT_MS = 3e4;
28
148
  function apiBaseUrl(baseUrl) {
29
149
  const url = new URL(baseUrl, globalThis.location?.href);
30
150
  if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
@@ -39,19 +159,9 @@ async function data(request) {
39
159
  if (result.data === void 0) throw new SyntaxError("Geo API returned an empty JSON response");
40
160
  return result.data;
41
161
  }
42
- function cacheControl(cache) {
43
- if (!cache) return void 0;
44
- const directives = [
45
- cache.refresh ? "no-cache" : void 0,
46
- cache.noStore ? "no-store" : void 0,
47
- cache.maxAge === void 0 ? void 0 : `max-age=${Math.max(0, Math.floor(cache.maxAge))}`,
48
- cache.onlyIfCached ? "only-if-cached" : void 0
49
- ].filter((directive) => directive !== void 0);
50
- return directives.length > 0 ? directives.join(", ") : void 0;
51
- }
52
162
  function lookupInit(options) {
53
163
  const { cache, ...request } = options ?? {};
54
- const header = cacheControl(cache);
164
+ const header = formatCacheControl(cache);
55
165
  return header ? { ...request, headers: { "cache-control": header } } : request;
56
166
  }
57
167
  function territory(client) {
@@ -73,7 +183,7 @@ function territory(client) {
73
183
  function createGeoClient(options) {
74
184
  const client = createClient({
75
185
  baseUrl: apiBaseUrl(options.baseUrl),
76
- fetch: options.fetch,
186
+ fetch: transport(options),
77
187
  headers: options.apiKey ? { "x-api-key": options.apiKey } : void 0
78
188
  });
79
189
  client.use({
@@ -90,6 +200,11 @@ function createGeoClient(options) {
90
200
  territory: territory(client)
91
201
  };
92
202
  }
203
+ function transport(options) {
204
+ const send = options.fetch ?? ((request) => globalThis.fetch(request));
205
+ const timed = timedFetch(send, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
206
+ return options.pacing === false ? timed : pacedFetch(timed, options.pacing);
207
+ }
93
208
  export {
94
209
  ApiError,
95
210
  createGeoClient
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seta-labs/geo",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Typed Fetch client for the Seta Geo HTTP API",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -44,6 +44,7 @@
44
44
  "devDependencies": {
45
45
  "@seta-geo/types": "workspace:*",
46
46
  "@seta-labs/config": "^0.16.1",
47
+ "@seta-labs/nestjs-cache-control": "0.2.1",
47
48
  "tsup": "^9.0.0",
48
49
  "typescript": "^6.0.3",
49
50
  "vitest": "^5.0.3"