@seta-labs/geo 0.6.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,41 @@
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
+
19
+ ## 0.7.0
20
+
21
+ - `reverseGeocode(input)` answers from a chain of sources: the ANNCSU register
22
+ and the OpenStreetMap house numbers loaded in seta-geo first (within 25 m),
23
+ TrueWay last (an exact street address within 50 m). Failing a number, the
24
+ named road TrueWay places the point on, as `match.level: 'VIA'` with
25
+ `streetNumber`, `location` and `distanceM` null. Never an approximate
26
+ number: with neither, `match` is null and `status` is `NO_RESULT`.
27
+ - The result gains `admin` (new type `GeoAdminArea`: `codComune`, `comune`,
28
+ `provincia`, `regione`), the municipality of the point from the ISTAT
29
+ boundaries, present even without a match; and `match` (new type
30
+ `GeoStreetMatch`): `level`, `provider` (`ANNCSU`, `OSM`, `TRUEWAY`),
31
+ `precision`, `street`, `streetNumber`, `postalCode`, `locality`,
32
+ `location`, `distanceM`, `sourceRef`.
33
+ - `address` is now composed from the match: `Via Ponte Schiavo 13, Roma (RM)`,
34
+ or `Via Ponte Schiavo, Roma (RM)` for a street alone.
35
+ - With `include: 'full'`, `results` is the raw answer of `match.provider`:
36
+ the TrueWay result list, or the ANNCSU/OSM record as one item.
37
+ - The request takes `maxDistanceM` (1-50), which can only tighten the limits.
38
+
3
39
  ## 0.6.0
4
40
 
5
41
  - `comune(codComune)` gains the `territorio` block (new type
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
 
@@ -111,30 +113,30 @@ interface paths {
111
113
  patch?: never;
112
114
  trace?: never;
113
115
  };
114
- '/cadastre/parcel': {
116
+ '/territory/status': {
115
117
  parameters: {
116
118
  query?: never;
117
119
  header?: never;
118
120
  path?: never;
119
121
  cookie?: never;
120
122
  };
121
- get?: never;
123
+ get: operations['TerritoryController_status'];
122
124
  put?: never;
123
- post: operations['CadastreController_parcel'];
125
+ post?: never;
124
126
  delete?: never;
125
127
  options?: never;
126
128
  head?: never;
127
129
  patch?: never;
128
130
  trace?: never;
129
131
  };
130
- '/cadastre/parcels': {
132
+ '/territory/datasets': {
131
133
  parameters: {
132
134
  query?: never;
133
135
  header?: never;
134
136
  path?: never;
135
137
  cookie?: never;
136
138
  };
137
- get: operations['CadastreController_listParcels'];
139
+ get: operations['TerritoryController_datasets'];
138
140
  put?: never;
139
141
  post?: never;
140
142
  delete?: never;
@@ -143,14 +145,14 @@ interface paths {
143
145
  patch?: never;
144
146
  trace?: never;
145
147
  };
146
- '/territory/status': {
148
+ '/territory/comuni': {
147
149
  parameters: {
148
150
  query?: never;
149
151
  header?: never;
150
152
  path?: never;
151
153
  cookie?: never;
152
154
  };
153
- get: operations['TerritoryController_status'];
155
+ get: operations['TerritoryController_listComuni'];
154
156
  put?: never;
155
157
  post?: never;
156
158
  delete?: never;
@@ -159,14 +161,14 @@ interface paths {
159
161
  patch?: never;
160
162
  trace?: never;
161
163
  };
162
- '/territory/datasets': {
164
+ '/territory/quotazioni': {
163
165
  parameters: {
164
166
  query?: never;
165
167
  header?: never;
166
168
  path?: never;
167
169
  cookie?: never;
168
170
  };
169
- get: operations['TerritoryController_datasets'];
171
+ get: operations['TerritoryController_listQuotazioni'];
170
172
  put?: never;
171
173
  post?: never;
172
174
  delete?: never;
@@ -175,14 +177,14 @@ interface paths {
175
177
  patch?: never;
176
178
  trace?: never;
177
179
  };
178
- '/territory/comuni': {
180
+ '/territory/compravendite': {
179
181
  parameters: {
180
182
  query?: never;
181
183
  header?: never;
182
184
  path?: never;
183
185
  cookie?: never;
184
186
  };
185
- get: operations['TerritoryController_listComuni'];
187
+ get: operations['TerritoryController_listCompravendite'];
186
188
  put?: never;
187
189
  post?: never;
188
190
  delete?: never;
@@ -191,14 +193,14 @@ interface paths {
191
193
  patch?: never;
192
194
  trace?: never;
193
195
  };
194
- '/territory/quotazioni': {
196
+ '/territory/comuni/{codComune}': {
195
197
  parameters: {
196
198
  query?: never;
197
199
  header?: never;
198
200
  path?: never;
199
201
  cookie?: never;
200
202
  };
201
- get: operations['TerritoryController_listQuotazioni'];
203
+ get: operations['TerritoryController_comune'];
202
204
  put?: never;
203
205
  post?: never;
204
206
  delete?: never;
@@ -207,48 +209,48 @@ interface paths {
207
209
  patch?: never;
208
210
  trace?: never;
209
211
  };
210
- '/territory/compravendite': {
212
+ '/territory/resolve': {
211
213
  parameters: {
212
214
  query?: never;
213
215
  header?: never;
214
216
  path?: never;
215
217
  cookie?: never;
216
218
  };
217
- get: operations['TerritoryController_listCompravendite'];
219
+ get?: never;
218
220
  put?: never;
219
- post?: never;
221
+ post: operations['TerritoryController_resolve'];
220
222
  delete?: never;
221
223
  options?: never;
222
224
  head?: never;
223
225
  patch?: never;
224
226
  trace?: never;
225
227
  };
226
- '/territory/comuni/{codComune}': {
228
+ '/cadastre/parcel': {
227
229
  parameters: {
228
230
  query?: never;
229
231
  header?: never;
230
232
  path?: never;
231
233
  cookie?: never;
232
234
  };
233
- get: operations['TerritoryController_comune'];
235
+ get?: never;
234
236
  put?: never;
235
- post?: never;
237
+ post: operations['CadastreController_parcel'];
236
238
  delete?: never;
237
239
  options?: never;
238
240
  head?: never;
239
241
  patch?: never;
240
242
  trace?: never;
241
243
  };
242
- '/territory/resolve': {
244
+ '/cadastre/parcels': {
243
245
  parameters: {
244
246
  query?: never;
245
247
  header?: never;
246
248
  path?: never;
247
249
  cookie?: never;
248
250
  };
249
- get?: never;
251
+ get: operations['CadastreController_listParcels'];
250
252
  put?: never;
251
- post: operations['TerritoryController_resolve'];
253
+ post?: never;
252
254
  delete?: never;
253
255
  options?: never;
254
256
  head?: never;
@@ -410,83 +412,68 @@ interface components {
410
412
  * @enum {string}
411
413
  */
412
414
  include?: 'best' | 'full';
415
+ /** @description Tightens the distance a house number may lie from the point (25 m for ANNCSU and OSM, 50 m for TrueWay); it never widens them. */
416
+ maxDistanceM?: number;
413
417
  };
414
- ReverseGeocodeResultDto: {
415
- /** @enum {string} */
416
- status: 'OK' | 'NO_RESULT';
417
- /** @description Canonical cache key: "lat,lng" rounded to 5 decimals (~1 m). */
418
- query: string;
419
- /** @description Formatted address of the best-tier result. */
420
- address: string | null;
421
- /** @description True when the answer came from the cache, no provider call. */
422
- cached: boolean;
423
- /** @description Only with include=full: the provider's result list exactly as returned (all tiers, all fields). Empty array = the provider answered with no results. Null = the raw response is not available for this cached row. */
424
- results?: {
425
- [key: string]: unknown;
426
- }[] | null;
418
+ GeoAdminAreaDto: {
419
+ /** @description Belfiore code, `H501`. */
420
+ codComune: string;
421
+ comune: string;
422
+ /** @description Province code, `RM`. */
423
+ provincia: string;
424
+ regione: string;
427
425
  };
428
- CadastreParcelRequestDto: {
426
+ GeoLocationDto: {
429
427
  lat: number;
430
428
  lng: number;
431
429
  };
432
- CadastreParcelResultDto: {
433
- /** @description Canonical cache key: "lat,lng" rounded to 5 decimals (~1 m). */
434
- query: string;
430
+ GeoStreetMatchDto: {
435
431
  /**
436
- * @description What lies under the point, verbatim from the map: PARTICELLA, STRADA, …
437
- * @example PARTICELLA
432
+ * @description How far down the address the match reaches: `CIVICO` a house number close enough, `VIA` only the street, when no source has a number close enough.
433
+ * @enum {string}
438
434
  */
439
- tipologia: string;
440
- /** @example B885 */
441
- codComune: string | null;
442
- /** @description Null when the map gives none: blanks and "null" strings are cleared. */
443
- sezione: string | null;
444
- /** @example 36 */
445
- foglio: string | null;
446
- /** @example G */
447
- allegato: string | null;
448
- sviluppo: string | null;
449
- /** @example 5101 */
450
- particella: string | null;
451
- /** @description The map answer exactly as returned (blank SEZIONE and "null" strings included). */
452
- raw: {
453
- [key: string]: unknown;
454
- };
455
- /** @description True when the answer came from the cache, no map call. */
456
- cached: boolean;
457
- };
458
- CadastreParcelRowDto: {
459
- /** @description Canonical cache key: "lat,lng" rounded to 5 decimals. */
460
- query: string;
461
- lat: number;
462
- lng: number;
463
- /** @example PARTICELLA */
464
- tipologia: string;
465
- /** @example B885 */
466
- codComune: string | null;
467
- /** @description Municipality name; null when the code is not in the registry. */
468
- comune: string | null;
469
- /** @example AL */
470
- provincia: string | null;
471
- sezione: string | null;
472
- /** @example 36 */
473
- foglio: string | null;
474
- /** @example G */
475
- allegato: string | null;
476
- sviluppo: string | null;
477
- /** @example 5101 */
478
- particella: string | null;
435
+ level: 'CIVICO' | 'VIA';
479
436
  /**
480
- * Format: date-time
481
- * @description When the map last gave this answer.
437
+ * @description The source that answered: ANNCSU and OSM are loaded locally, TrueWay is paid.
438
+ * @enum {string}
482
439
  */
483
- updatedAt: string;
440
+ provider: 'ANNCSU' | 'OSM' | 'TRUEWAY';
441
+ /** @description How the source rates the position: `metodo-1`…`metodo-5` (ANNCSU), `nodo` or `edificio` (OSM), `exact` (TrueWay), `centroid` (a TrueWay street). */
442
+ precision: string;
443
+ street: string;
444
+ /** @description `12`, `12/A`, `12 rosso`; null at level `VIA`. */
445
+ streetNumber: string | null;
446
+ /** @description Null from ANNCSU, which has none. */
447
+ postalCode: string | null;
448
+ /** @description Hamlet or locality as the source names it; the municipality is in `admin`. */
449
+ locality: string | null;
450
+ /** @description Where the house number is; null for a street the source places at its centroid. */
451
+ location: components['schemas']['GeoLocationDto'] | null;
452
+ /** @description Metres from the requested point to `location`; null without a location. */
453
+ distanceM: number | null;
454
+ /** @description Id of the record in its source: ANNCSU access, OSM `n123`/`w456`. */
455
+ sourceRef: string | null;
484
456
  };
485
- CadastreParcelsPageDto: {
486
- page: number;
487
- pageSize: number;
488
- total: number;
489
- items: components['schemas']['CadastreParcelRowDto'][];
457
+ ReverseGeocodeResultDto: {
458
+ /**
459
+ * @description OK when `match` is present.
460
+ * @enum {string}
461
+ */
462
+ status: 'OK' | 'NO_RESULT';
463
+ /** @description Canonical cache key: "lat,lng" rounded to 5 decimals (~1 m). */
464
+ query: string;
465
+ /** @description The match as one line: `Via Ponte Schiavo 13, Roma (RM)`, or `Via Ponte Schiavo, Roma (RM)` at level `VIA`. */
466
+ address: string | null;
467
+ /** @description Municipality of the point from the ISTAT boundaries, whatever the match; null at sea, abroad or while no boundary edition is loaded. */
468
+ admin: components['schemas']['GeoAdminAreaDto'] | null;
469
+ /** @description An exact house number close enough, else the street a source names (level `VIA`); null when neither: never an approximate number. */
470
+ match: components['schemas']['GeoStreetMatchDto'] | null;
471
+ /** @description True when the answer came from stored rows: no source was asked again. */
472
+ cached: boolean;
473
+ /** @description Only with include=full: the raw answer of the source in `match.provider` (the TrueWay result list, or the ANNCSU/OSM record as one item); without a match, the raw answer of the last source asked. */
474
+ results?: {
475
+ [key: string]: unknown;
476
+ }[] | null;
490
477
  };
491
478
  TerritoryStatusDto: {
492
479
  /** @description Latest OMI publication loaded (`YYYY-S`), null while no supply is loaded. */
@@ -651,7 +638,7 @@ interface components {
651
638
  TerritoryComuneDetailDto: {
652
639
  comune: components['schemas']['TerritoryComuneDto'];
653
640
  istat: components['schemas']['TerritoryIstatDto'];
654
- /** @description Null for a suppressed municipality: SITUAS describes only the current ones. */
641
+ /** @description Latest SITUAS edition that describes the municipality; null when none does, as for one suppressed before the first edition loaded. */
655
642
  territorio: components['schemas']['TerritoryTerritorioDto'] | null;
656
643
  /** @description Publication the zone quotations come from, null while none is loaded. */
657
644
  semestre: string | null;
@@ -687,6 +674,69 @@ interface components {
687
674
  TerritoryResolveResponseDto: {
688
675
  items: components['schemas']['TerritoryPointResultDto'][];
689
676
  };
677
+ CadastreParcelRequestDto: {
678
+ lat: number;
679
+ lng: number;
680
+ };
681
+ CadastreParcelResultDto: {
682
+ /** @description Canonical cache key: "lat,lng" rounded to 5 decimals (~1 m). */
683
+ query: string;
684
+ /**
685
+ * @description What lies under the point, verbatim from the map: PARTICELLA, STRADA, …
686
+ * @example PARTICELLA
687
+ */
688
+ tipologia: string;
689
+ /** @example B885 */
690
+ codComune: string | null;
691
+ /** @description Null when the map gives none: blanks and "null" strings are cleared. */
692
+ sezione: string | null;
693
+ /** @example 36 */
694
+ foglio: string | null;
695
+ /** @example G */
696
+ allegato: string | null;
697
+ sviluppo: string | null;
698
+ /** @example 5101 */
699
+ particella: string | null;
700
+ /** @description The map answer exactly as returned (blank SEZIONE and "null" strings included). */
701
+ raw: {
702
+ [key: string]: unknown;
703
+ };
704
+ /** @description True when the answer came from the cache, no map call. */
705
+ cached: boolean;
706
+ };
707
+ CadastreParcelRowDto: {
708
+ /** @description Canonical cache key: "lat,lng" rounded to 5 decimals. */
709
+ query: string;
710
+ lat: number;
711
+ lng: number;
712
+ /** @example PARTICELLA */
713
+ tipologia: string;
714
+ /** @example B885 */
715
+ codComune: string | null;
716
+ /** @description Municipality name; null when the code is not in the registry. */
717
+ comune: string | null;
718
+ /** @example AL */
719
+ provincia: string | null;
720
+ sezione: string | null;
721
+ /** @example 36 */
722
+ foglio: string | null;
723
+ /** @example G */
724
+ allegato: string | null;
725
+ sviluppo: string | null;
726
+ /** @example 5101 */
727
+ particella: string | null;
728
+ /**
729
+ * Format: date-time
730
+ * @description When the map last gave this answer.
731
+ */
732
+ updatedAt: string;
733
+ };
734
+ CadastreParcelsPageDto: {
735
+ page: number;
736
+ pageSize: number;
737
+ total: number;
738
+ items: components['schemas']['CadastreParcelRowDto'][];
739
+ };
690
740
  };
691
741
  responses: never;
692
742
  parameters: never;
@@ -894,97 +944,6 @@ interface operations {
894
944
  };
895
945
  };
896
946
  };
897
- CadastreController_parcel: {
898
- parameters: {
899
- query?: never;
900
- header?: {
901
- /** @description no-cache: ask the provider and store its answer; no-store: never write the store; max-age=N: a stored answer older than N seconds is asked again; only-if-cached: never ask the provider. The response says what happened in Cache-Status (hit, fwd=miss|request|stale, stored) and, on a hit, Age. */
902
- 'Cache-Control'?: string;
903
- };
904
- path?: never;
905
- cookie?: never;
906
- };
907
- requestBody: {
908
- content: {
909
- 'application/json': components['schemas']['CadastreParcelRequestDto'];
910
- };
911
- };
912
- responses: {
913
- /** @description What the AdE cadastral map finds under the point: a parcel, a street, … */
914
- 200: {
915
- headers: {
916
- [name: string]: unknown;
917
- };
918
- content: {
919
- 'application/json': components['schemas']['CadastreParcelResultDto'];
920
- };
921
- };
922
- /** @description Missing or invalid API key. */
923
- 401: {
924
- headers: {
925
- [name: string]: unknown;
926
- };
927
- content?: never;
928
- };
929
- /** @description CADASTRE_UNAVAILABLE or CADASTRE_INVALID_REPLY: the map gave no answer. */
930
- 502: {
931
- headers: {
932
- [name: string]: unknown;
933
- };
934
- content?: never;
935
- };
936
- /** @description CADASTRE_BLOCKED: the map is refusing calls; Retry-After says when to try again. */
937
- 503: {
938
- headers: {
939
- [name: string]: unknown;
940
- };
941
- content?: never;
942
- };
943
- /** @description NOT_CACHED: only-if-cached and no usable stored answer. */
944
- 504: {
945
- headers: {
946
- [name: string]: unknown;
947
- };
948
- content?: never;
949
- };
950
- };
951
- };
952
- CadastreController_listParcels: {
953
- parameters: {
954
- query?: {
955
- page?: number;
956
- pageSize?: number;
957
- /** @description Cadastral municipality code. */
958
- codComune?: string;
959
- foglio?: string;
960
- particella?: string;
961
- /** @description What lies under the point. */
962
- tipologia?: string;
963
- };
964
- header?: never;
965
- path?: never;
966
- cookie?: never;
967
- };
968
- requestBody?: never;
969
- responses: {
970
- /** @description The stored map answers, newest first; never a map call. */
971
- 200: {
972
- headers: {
973
- [name: string]: unknown;
974
- };
975
- content: {
976
- 'application/json': components['schemas']['CadastreParcelsPageDto'];
977
- };
978
- };
979
- /** @description Missing or invalid API key. */
980
- 401: {
981
- headers: {
982
- [name: string]: unknown;
983
- };
984
- content?: never;
985
- };
986
- };
987
- };
988
947
  TerritoryController_status: {
989
948
  parameters: {
990
949
  query?: never;
@@ -1214,6 +1173,97 @@ interface operations {
1214
1173
  };
1215
1174
  };
1216
1175
  };
1176
+ CadastreController_parcel: {
1177
+ parameters: {
1178
+ query?: never;
1179
+ header?: {
1180
+ /** @description no-cache: ask the provider and store its answer; no-store: never write the store; max-age=N: a stored answer older than N seconds is asked again; only-if-cached: never ask the provider. The response says what happened in Cache-Status (hit, fwd=miss|request|stale, stored) and, on a hit, Age. */
1181
+ 'Cache-Control'?: string;
1182
+ };
1183
+ path?: never;
1184
+ cookie?: never;
1185
+ };
1186
+ requestBody: {
1187
+ content: {
1188
+ 'application/json': components['schemas']['CadastreParcelRequestDto'];
1189
+ };
1190
+ };
1191
+ responses: {
1192
+ /** @description What the AdE cadastral map finds under the point: a parcel, a street, … */
1193
+ 200: {
1194
+ headers: {
1195
+ [name: string]: unknown;
1196
+ };
1197
+ content: {
1198
+ 'application/json': components['schemas']['CadastreParcelResultDto'];
1199
+ };
1200
+ };
1201
+ /** @description Missing or invalid API key. */
1202
+ 401: {
1203
+ headers: {
1204
+ [name: string]: unknown;
1205
+ };
1206
+ content?: never;
1207
+ };
1208
+ /** @description CADASTRE_UNAVAILABLE or CADASTRE_INVALID_REPLY: the map gave no answer. */
1209
+ 502: {
1210
+ headers: {
1211
+ [name: string]: unknown;
1212
+ };
1213
+ content?: never;
1214
+ };
1215
+ /** @description CADASTRE_BLOCKED: the map is refusing calls; Retry-After says when to try again. */
1216
+ 503: {
1217
+ headers: {
1218
+ [name: string]: unknown;
1219
+ };
1220
+ content?: never;
1221
+ };
1222
+ /** @description NOT_CACHED: only-if-cached and no usable stored answer. */
1223
+ 504: {
1224
+ headers: {
1225
+ [name: string]: unknown;
1226
+ };
1227
+ content?: never;
1228
+ };
1229
+ };
1230
+ };
1231
+ CadastreController_listParcels: {
1232
+ parameters: {
1233
+ query?: {
1234
+ page?: number;
1235
+ pageSize?: number;
1236
+ /** @description Cadastral municipality code. */
1237
+ codComune?: string;
1238
+ foglio?: string;
1239
+ particella?: string;
1240
+ /** @description What lies under the point. */
1241
+ tipologia?: string;
1242
+ };
1243
+ header?: never;
1244
+ path?: never;
1245
+ cookie?: never;
1246
+ };
1247
+ requestBody?: never;
1248
+ responses: {
1249
+ /** @description The stored map answers, newest first; never a map call. */
1250
+ 200: {
1251
+ headers: {
1252
+ [name: string]: unknown;
1253
+ };
1254
+ content: {
1255
+ 'application/json': components['schemas']['CadastreParcelsPageDto'];
1256
+ };
1257
+ };
1258
+ /** @description Missing or invalid API key. */
1259
+ 401: {
1260
+ headers: {
1261
+ [name: string]: unknown;
1262
+ };
1263
+ content?: never;
1264
+ };
1265
+ };
1266
+ };
1217
1267
  }
1218
1268
 
1219
1269
  type ApiPaths = paths;
@@ -1224,6 +1274,8 @@ type GeocodeResult = ApiSchemas['GeocodeResultDto'];
1224
1274
  type GeocodeBatchResponse = ApiSchemas['GeocodeBatchResponseDto'];
1225
1275
  type ReverseGeocodeRequest = ApiSchemas['ReverseGeocodeRequestDto'];
1226
1276
  type ReverseGeocodeResult = ApiSchemas['ReverseGeocodeResultDto'];
1277
+ type GeoStreetMatch = ApiSchemas['GeoStreetMatchDto'];
1278
+ type GeoAdminArea = ApiSchemas['GeoAdminAreaDto'];
1227
1279
  type CadastreParcelRequest = ApiSchemas['CadastreParcelRequestDto'];
1228
1280
  type CadastreParcelResult = ApiSchemas['CadastreParcelResultDto'];
1229
1281
  type CadastreParcelsPage = ApiSchemas['CadastreParcelsPageDto'];
@@ -1244,6 +1296,31 @@ type TerritoryQuotazione = ApiSchemas['TerritoryQuotazioneDto'];
1244
1296
  type TerritoryCompravenditePage = ApiSchemas['TerritoryCompravenditePageDto'];
1245
1297
  type TerritoryCompravendita = ApiSchemas['TerritoryCompravenditaDto'];
1246
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
+
1247
1324
  type TerritoryComuniQuery = NonNullable<ApiPaths['/territory/comuni']['get']['parameters']['query']>;
1248
1325
  type TerritoryQuotazioniQuery = NonNullable<ApiPaths['/territory/quotazioni']['get']['parameters']['query']>;
1249
1326
  type TerritoryCompravenditeQuery = NonNullable<ApiPaths['/territory/compravendite']['get']['parameters']['query']>;
@@ -1252,21 +1329,12 @@ interface RequestOptions {
1252
1329
  signal?: AbortSignal;
1253
1330
  }
1254
1331
  /**
1255
- * How a lookup may use seta-geo's store, sent as `Cache-Control` (RFC 9111).
1256
- * 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.
1257
1335
  */
1258
- interface LookupCache {
1259
- /** `no-cache`: ask the provider even with a stored answer, and store the new one. */
1260
- refresh?: boolean;
1261
- /** `no-store`: never write the provider's answer (with `refresh`: a pure bypass). */
1262
- noStore?: boolean;
1263
- /** `max-age`: a stored answer older than this many seconds is asked again. */
1264
- maxAge?: number;
1265
- /** `only-if-cached`: never ask the provider; 504 `NOT_CACHED` without a stored answer. */
1266
- onlyIfCached?: boolean;
1267
- }
1268
1336
  interface LookupOptions extends RequestOptions {
1269
- cache?: LookupCache;
1337
+ cache?: CachePolicy;
1270
1338
  }
1271
1339
  interface GeoClientOptions {
1272
1340
  /** Complete API root, including /api. Relative roots are supported in browsers. */
@@ -1275,6 +1343,10 @@ interface GeoClientOptions {
1275
1343
  apiKey?: string;
1276
1344
  /** A real Fetch implementation returning a Response, not a parsed-body $fetch. */
1277
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;
1278
1350
  }
1279
1351
  interface TerritoryClient {
1280
1352
  status(options?: RequestOptions): Promise<TerritoryStatus>;
@@ -1300,4 +1372,4 @@ interface GeoClient {
1300
1372
  declare function createGeoClient(options: GeoClientOptions): GeoClient;
1301
1373
 
1302
1374
  export { ApiError, createGeoClient };
1303
- export type { CadastreParcelRequest, CadastreParcelResult, CadastreParcelRow, CadastreParcelsPage, CadastreParcelsQuery, GeoClient, GeoClientOptions, 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.6.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"