@mailwoman/api 9.0.0 → 9.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 +1 -1
- package/app.ts +13 -8
- package/engine.ts +23 -11
- package/out/app.d.ts +2 -1
- package/out/app.d.ts.map +1 -1
- package/out/app.js +7 -7
- package/out/app.js.map +1 -1
- package/out/engine.d.ts +18 -22
- package/out/engine.d.ts.map +1 -1
- package/out/routes.d.ts +2 -1
- package/out/routes.d.ts.map +1 -1
- package/out/routes.js +28 -32
- package/out/routes.js.map +1 -1
- package/out/schema.d.ts +383 -3
- package/out/schema.d.ts.map +1 -1
- package/out/schema.js +133 -7
- package/out/schema.js.map +1 -1
- package/package.json +22 -7
- package/routes.ts +46 -35
- package/schema.ts +165 -29
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mailwoman/api",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.2.0",
|
|
4
4
|
"description": "The native Mailwoman HTTP API — engine-agnostic /v1 surface (parse, geocode, batch, resolve, format) with health, metrics, and an emitted OpenAPI document.",
|
|
5
5
|
"license": "AGPL-3.0-only OR LicenseRef-Commercial",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "https://github.com/sister-software/mailwoman.git",
|
|
9
|
-
"directory": "api"
|
|
9
|
+
"directory": "packages/api"
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"out/**/*.js",
|
|
@@ -21,14 +21,22 @@
|
|
|
21
21
|
"!*.test.ts",
|
|
22
22
|
"!*.test.tsx",
|
|
23
23
|
"!**/*.test.ts",
|
|
24
|
-
"!**/*.test.tsx"
|
|
24
|
+
"!**/*.test.tsx",
|
|
25
|
+
"!test/**"
|
|
25
26
|
],
|
|
26
27
|
"type": "module",
|
|
28
|
+
"imports": {
|
|
29
|
+
"#package.json": "./package.json"
|
|
30
|
+
},
|
|
27
31
|
"exports": {
|
|
28
32
|
"./package.json": "./package.json",
|
|
29
33
|
".": {
|
|
30
34
|
"types": "./out/index.d.ts",
|
|
31
35
|
"default": "./out/index.js"
|
|
36
|
+
},
|
|
37
|
+
"./schema": {
|
|
38
|
+
"types": "./out/schema.d.ts",
|
|
39
|
+
"default": "./out/schema.js"
|
|
32
40
|
}
|
|
33
41
|
},
|
|
34
42
|
"publishConfig": {
|
|
@@ -38,15 +46,22 @@
|
|
|
38
46
|
".": {
|
|
39
47
|
"types": "./out/index.d.ts",
|
|
40
48
|
"default": "./out/index.js"
|
|
49
|
+
},
|
|
50
|
+
"./schema": {
|
|
51
|
+
"types": "./out/schema.d.ts",
|
|
52
|
+
"default": "./out/schema.js"
|
|
41
53
|
}
|
|
54
|
+
},
|
|
55
|
+
"imports": {
|
|
56
|
+
"#package.json": "./package.json"
|
|
42
57
|
}
|
|
43
58
|
},
|
|
44
59
|
"dependencies": {
|
|
45
60
|
"@hono/zod-openapi": "^1.5.1",
|
|
46
|
-
"@mailwoman/api-kit": "9.
|
|
47
|
-
"@mailwoman/core": "9.
|
|
48
|
-
"@mailwoman/formatter": "9.
|
|
49
|
-
"hono": "^4.
|
|
61
|
+
"@mailwoman/api-kit": "9.2.0",
|
|
62
|
+
"@mailwoman/core": "9.2.0",
|
|
63
|
+
"@mailwoman/formatter": "9.2.0",
|
|
64
|
+
"hono": "^4.13.0",
|
|
50
65
|
"zod": "^4.4.3"
|
|
51
66
|
}
|
|
52
67
|
}
|
package/routes.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { createRoute, type OpenAPIHono, z } from "@hono/zod-openapi"
|
|
23
|
-
import {
|
|
23
|
+
import { geocoderUnavailableError, metricsSnapshot, recordTimed } from "@mailwoman/api-kit"
|
|
24
24
|
import type { AddressTree } from "@mailwoman/core/decoder"
|
|
25
25
|
import type { ComponentTag } from "@mailwoman/core/types"
|
|
26
26
|
import { canonicalKey, type ComponentDict, formatAddress, type FormatAddressOptions } from "@mailwoman/formatter"
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
ParseRequestSchema,
|
|
40
40
|
ResolveRequestSchema,
|
|
41
41
|
ResolveResponseSchema,
|
|
42
|
+
type GeocodeOutcome,
|
|
42
43
|
} from "./schema.ts"
|
|
43
44
|
|
|
44
45
|
/**
|
|
@@ -50,15 +51,6 @@ export const DEFAULT_BATCH_MAX = 500
|
|
|
50
51
|
|
|
51
52
|
const startedAt = Date.now()
|
|
52
53
|
|
|
53
|
-
/**
|
|
54
|
-
* `detail` text for every 503 "engine method absent" response (`/v1/geocode`, `/v1/batch`, `/v1/resolve`, `/v1/reload`)
|
|
55
|
-
* — the express-era remediation carried forward: a stranger hitting a 503 must see the exact fix, not just "not
|
|
56
|
-
* available". Matches `mailwoman/api-engine.ts`'s `buildPreflightMessage()` boot-time banner in spirit (same two
|
|
57
|
-
* missing pieces — the packages, and the gazetteer data), condensed to one line for a JSON error body.
|
|
58
|
-
*/
|
|
59
|
-
const GEOCODER_UNAVAILABLE_DETAIL =
|
|
60
|
-
"install @mailwoman/neural + @mailwoman/resolver-wof-sqlite and provide gazetteer data (MAILWOMAN_WOF_DB / MAILWOMAN_CANDIDATE_DB)"
|
|
61
|
-
|
|
62
54
|
/**
|
|
63
55
|
* Options for {@link registerMailwomanAPIRoutes}.
|
|
64
56
|
*/
|
|
@@ -261,15 +253,16 @@ function toComponentDict(components: Record<string, string | string[]>): Compone
|
|
|
261
253
|
/**
|
|
262
254
|
* Register the native `/v1` routes + `/health` + `/metrics` against an injected engine.
|
|
263
255
|
*/
|
|
264
|
-
export function registerMailwomanAPIRoutes(
|
|
256
|
+
export function registerMailwomanAPIRoutes<T extends Partial<GeocodeOutcome> = GeocodeOutcome>(
|
|
265
257
|
app: OpenAPIHono,
|
|
266
|
-
engine: MailwomanAPIEngine
|
|
258
|
+
engine: MailwomanAPIEngine<T>,
|
|
267
259
|
options: RegisterMailwomanAPIRoutesOptions = {}
|
|
268
260
|
): void {
|
|
269
261
|
const batchMax = options.batchMax ?? DEFAULT_BATCH_MAX
|
|
270
262
|
|
|
271
263
|
app.openapi(parseGetRoute, async (c) => {
|
|
272
264
|
if (!engine.parse) return c.json({ error: "parse not implemented" }, 501)
|
|
265
|
+
|
|
273
266
|
const address = c.req.query("address")?.trim()
|
|
274
267
|
|
|
275
268
|
if (!address) return c.json({ error: "address is required" }, 400)
|
|
@@ -284,11 +277,17 @@ export function registerMailwomanAPIRoutes(
|
|
|
284
277
|
app.openapi(
|
|
285
278
|
parsePostRoute,
|
|
286
279
|
async (c) => {
|
|
287
|
-
if (!engine.parse)
|
|
280
|
+
if (!engine.parse) {
|
|
281
|
+
return c.json({ error: "parse not implemented" }, 501)
|
|
282
|
+
}
|
|
283
|
+
|
|
288
284
|
const { address, debug, input_mode } = c.req.valid("json")
|
|
289
285
|
const trimmed = address.trim()
|
|
290
286
|
|
|
291
|
-
if (!trimmed)
|
|
287
|
+
if (!trimmed) {
|
|
288
|
+
return c.json({ error: "address is required" }, 400)
|
|
289
|
+
}
|
|
290
|
+
|
|
292
291
|
const outcome = await engine.parse(trimmed, { debug: debug ?? false, inputMode: input_mode })
|
|
293
292
|
|
|
294
293
|
return c.json(outcome, 200)
|
|
@@ -303,29 +302,33 @@ export function registerMailwomanAPIRoutes(
|
|
|
303
302
|
app.openapi(
|
|
304
303
|
geocodeRoute,
|
|
305
304
|
async (c) => {
|
|
306
|
-
if (!engine.geocode)
|
|
305
|
+
if (!engine.geocode) {
|
|
306
|
+
return geocoderUnavailableError(c)
|
|
307
|
+
}
|
|
308
|
+
|
|
307
309
|
const { address, input_mode } = c.req.valid("json")
|
|
308
310
|
const trimmed = address.trim()
|
|
309
311
|
|
|
310
312
|
if (!trimmed) return c.json({ error: "address is required" }, 400)
|
|
311
313
|
const t0 = performance.now()
|
|
312
314
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
}
|
|
315
|
+
return engine
|
|
316
|
+
.geocode(trimmed, { inputMode: input_mode })
|
|
317
|
+
.then((outcome) => {
|
|
318
|
+
recordTimed(performance.now() - t0, String(outcome.resolution_tier ?? "admin"))
|
|
319
|
+
|
|
320
|
+
return c.json(outcome as unknown as GeocodeOutcome, 200)
|
|
321
|
+
})
|
|
322
|
+
.catch((error) => {
|
|
323
|
+
recordTimed(performance.now() - t0, "error")
|
|
324
|
+
|
|
325
|
+
throw error
|
|
326
|
+
})
|
|
326
327
|
},
|
|
327
328
|
(result, c) => {
|
|
328
|
-
if (!result.success)
|
|
329
|
+
if (!result.success) {
|
|
330
|
+
return c.json({ error: "address is required" }, 400)
|
|
331
|
+
}
|
|
329
332
|
|
|
330
333
|
return undefined
|
|
331
334
|
}
|
|
@@ -342,7 +345,9 @@ export function registerMailwomanAPIRoutes(
|
|
|
342
345
|
return c.json({ error: `batch too large: ${addresses.length} > ${batchMax}` }, 413)
|
|
343
346
|
}
|
|
344
347
|
|
|
345
|
-
if (!engine.batch)
|
|
348
|
+
if (!engine.batch) {
|
|
349
|
+
return geocoderUnavailableError(c)
|
|
350
|
+
}
|
|
346
351
|
|
|
347
352
|
// Whole-call latency, recorded under the "batch" tier. Per-row tier metrics are the ENGINE's
|
|
348
353
|
// responsibility (phase 4b) — this app only times the call as a unit.
|
|
@@ -375,11 +380,14 @@ export function registerMailwomanAPIRoutes(
|
|
|
375
380
|
// street node's stamped resolution tier per call — the wired engine must carry that over, and
|
|
376
381
|
// must trim batch rows the same way (the route passes raw input through).
|
|
377
382
|
async (c) => {
|
|
378
|
-
|
|
383
|
+
// `resolver`, not `geocoder` — the missing method is `engine.resolveTree`, and the 503's `error`
|
|
384
|
+
// value is what a caller branches on.
|
|
385
|
+
if (!engine.resolveTree) {
|
|
386
|
+
return geocoderUnavailableError(c, "resolver")
|
|
387
|
+
}
|
|
388
|
+
|
|
379
389
|
const { tree, opts } = c.req.valid("json")
|
|
380
|
-
|
|
381
|
-
// boundary onto the engine's `AddressTree` contract, matching the established idiom (api-kit's
|
|
382
|
-
// `openapi.ts`, the drop-ins' response casts) for "documented wire shape looser than the domain type".
|
|
390
|
+
|
|
383
391
|
const outcome = await engine.resolveTree(tree as unknown as AddressTree, opts ?? {})
|
|
384
392
|
|
|
385
393
|
return c.json(outcome, 200)
|
|
@@ -392,7 +400,10 @@ export function registerMailwomanAPIRoutes(
|
|
|
392
400
|
)
|
|
393
401
|
|
|
394
402
|
app.openapi(reloadRoute, async (c) => {
|
|
395
|
-
if (!engine.reload)
|
|
403
|
+
if (!engine.reload) {
|
|
404
|
+
return geocoderUnavailableError(c)
|
|
405
|
+
}
|
|
406
|
+
|
|
396
407
|
const outcome = await engine.reload()
|
|
397
408
|
|
|
398
409
|
return c.json(outcome, 200)
|
package/schema.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* libpostal), nothing here is a vendor contract — this surface is ours to design, so request
|
|
8
8
|
* bodies are REQUIRED and validator-enforced (no legacy tolerance to preserve). A `defaultHook`
|
|
9
9
|
* on the app maps validation failures through the shared `APIErrorSchema` envelope
|
|
10
|
-
* (`
|
|
10
|
+
* (`errorResponse(c, 400, "invalid request body", <zod summary>)`) — the pattern boundary every
|
|
11
11
|
* surface holds to: where no legacy contract exists, the validator MAY speak, but only in
|
|
12
12
|
* our envelope.
|
|
13
13
|
*
|
|
@@ -98,6 +98,9 @@ const GeocodeHierarchyEntrySchema = z
|
|
|
98
98
|
lat: z.number().optional(),
|
|
99
99
|
lon: z.number().optional(),
|
|
100
100
|
placeID: z.string().optional(),
|
|
101
|
+
// #1731 tri-state lineage provenance: true = the winner's ancestor chain vouches for this entry, false =
|
|
102
|
+
// resolved independently OUTSIDE the winner's lineage, absent = unverifiable. Absence is not false.
|
|
103
|
+
in_winner_lineage: z.boolean().optional(),
|
|
101
104
|
})
|
|
102
105
|
.openapi("GeocodeHierarchyEntry")
|
|
103
106
|
|
|
@@ -116,6 +119,80 @@ const GeocodeCandidateSchema = z
|
|
|
116
119
|
})
|
|
117
120
|
.openapi("GeocodeCandidate")
|
|
118
121
|
|
|
122
|
+
/**
|
|
123
|
+
* The `ComponentTag` union at this engine-agnostic boundary, named once so every schema that speaks about a tag speaks
|
|
124
|
+
* about the SAME list. Two hand-copied enums would agree on the day they were written and diverge on the day a tag is
|
|
125
|
+
* added — the shape of defect `feedback-parity-needs-shared-function-not-shared-constants` describes.
|
|
126
|
+
*/
|
|
127
|
+
const ComponentTagSchema = z.enum([
|
|
128
|
+
"country",
|
|
129
|
+
"region",
|
|
130
|
+
"locality",
|
|
131
|
+
"dependent_locality",
|
|
132
|
+
"postcode",
|
|
133
|
+
"subregion",
|
|
134
|
+
"house_number",
|
|
135
|
+
"street",
|
|
136
|
+
"street_prefix",
|
|
137
|
+
"street_prefix_particle",
|
|
138
|
+
"street_suffix",
|
|
139
|
+
"intersection_a",
|
|
140
|
+
"intersection_b",
|
|
141
|
+
"unit",
|
|
142
|
+
"venue",
|
|
143
|
+
"attention",
|
|
144
|
+
"po_box",
|
|
145
|
+
"cedex",
|
|
146
|
+
"prefecture",
|
|
147
|
+
"municipality",
|
|
148
|
+
"district",
|
|
149
|
+
"block",
|
|
150
|
+
"sub_block",
|
|
151
|
+
"building_number",
|
|
152
|
+
"building_name",
|
|
153
|
+
])
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Canonical parsed-component map carried by `GeocodeResult.components`. Spelled out at this engine-agnostic API
|
|
157
|
+
* boundary for the same reason the result schema is hand-modeled; the compile-time drift pin in
|
|
158
|
+
* `mailwoman/test/api-schema-drift.test.ts` catches any mismatch with the real `ComponentTag`-keyed result type.
|
|
159
|
+
*/
|
|
160
|
+
const GeocodeComponentsSchema = z.partialRecord(ComponentTagSchema, z.string())
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* One `GeocodeOutcome.intent_markers` entry — an advisory the ROAD_TO_V9 §4 intent vocabulary raised about the QUERY.
|
|
164
|
+
* Mirrors `QueryIntentMarker` (`core/pipeline/types.ts`).
|
|
165
|
+
*
|
|
166
|
+
* `evidence` is deliberately open (`z.record`): each `code` carries its own measurement — a dominance margin, a pair of
|
|
167
|
+
* interpretations, a taxonomy id — and flattening those into one closed shape would either lose the numbers or invent
|
|
168
|
+
* fields that do not apply. `code` is the discriminator a client branches on.
|
|
169
|
+
*/
|
|
170
|
+
const QueryIntentMarkerSchema = z
|
|
171
|
+
.object({
|
|
172
|
+
// Spelled out rather than `z.string()` so `mailwoman/test/api-schema-drift.test.ts`'s schema-too-wide direction
|
|
173
|
+
// keeps biting: a new `QueryKind` that never reaches this list is a documented contract that has quietly stopped
|
|
174
|
+
// describing the real one.
|
|
175
|
+
kind: z.enum([
|
|
176
|
+
"postcode_only",
|
|
177
|
+
"locality_only",
|
|
178
|
+
"structured_address",
|
|
179
|
+
"intersection",
|
|
180
|
+
"po_box",
|
|
181
|
+
"landmark",
|
|
182
|
+
"poi_query",
|
|
183
|
+
"vague",
|
|
184
|
+
"bare_toponym",
|
|
185
|
+
"route_pair",
|
|
186
|
+
"near_me",
|
|
187
|
+
"poi_category",
|
|
188
|
+
]),
|
|
189
|
+
code: z.enum(["declared_ambiguity", "declared_fork", "focus_point_required", "poi_category"]),
|
|
190
|
+
mechanism: z.string(),
|
|
191
|
+
message: z.string(),
|
|
192
|
+
evidence: z.record(z.string(), z.unknown()).optional(),
|
|
193
|
+
})
|
|
194
|
+
.openapi("QueryIntentMarker")
|
|
195
|
+
|
|
119
196
|
/**
|
|
120
197
|
* `POST /v1/geocode` response — a hand-modeled mirror of `GeocodeResult`'s wire shape (`mailwoman/geocode-core.ts`),
|
|
121
198
|
* `.loose()` so a field the engine adds that this schema doesn't yet know about still rides through undocumented rather
|
|
@@ -126,34 +203,93 @@ const GeocodeCandidateSchema = z
|
|
|
126
203
|
* `@mailwoman/api`, never the reverse). `mailwoman/test/api-schema-drift.test.ts` is the compile-time tripwire that
|
|
127
204
|
* catches this shape drifting from the real `GeocodeResult` interface.
|
|
128
205
|
*/
|
|
129
|
-
export const
|
|
130
|
-
.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
.
|
|
206
|
+
export const GeocodeOutcomeLikeSchema = z.object({
|
|
207
|
+
input: z.string(),
|
|
208
|
+
components: GeocodeComponentsSchema,
|
|
209
|
+
lat: z.number().nullable(),
|
|
210
|
+
lon: z.number().nullable(),
|
|
211
|
+
resolution_tier: z.enum(["address_point", "interpolated", "street", "admin", "venue", "plus_code"]),
|
|
212
|
+
// The fork→entity probe's answer (#1585) — present only on the `venue` tier; see geocode-core's
|
|
213
|
+
// GeocodeResult.entity.
|
|
214
|
+
entity: z
|
|
215
|
+
.object({
|
|
216
|
+
name: z.string(),
|
|
217
|
+
categoryID: z.string().nullable(),
|
|
218
|
+
confidence: z.number(),
|
|
219
|
+
country: z.string(),
|
|
220
|
+
})
|
|
221
|
+
.optional(),
|
|
222
|
+
uncertainty_m: z.number().nullable(),
|
|
223
|
+
locality: z.string().nullable(),
|
|
224
|
+
region: z.string().nullable(),
|
|
225
|
+
postcode: z.string().nullable(),
|
|
226
|
+
house_number: z.string().nullable(),
|
|
227
|
+
street: z.string().nullable(),
|
|
228
|
+
// The parsed venue span (#1041 posture; surfaced 2026-08-01 for the hierarchy-evidence campaign R1).
|
|
229
|
+
venue: z.string().nullable(),
|
|
230
|
+
// The parsed dependent-locality span (parse view; `hierarchy` is the resolved view).
|
|
231
|
+
dependent_locality: z.string().nullable(),
|
|
232
|
+
// The parsed unit / sub-venue span (parse view) — "Terminal 5", "Suite 300".
|
|
233
|
+
unit: z.string().nullable(),
|
|
234
|
+
countryCode: z.string().nullable(),
|
|
235
|
+
hierarchy: z.array(GeocodeHierarchyEntrySchema),
|
|
236
|
+
candidates: z.array(GeocodeCandidateSchema),
|
|
237
|
+
// The register row's OWN scope tags when the address_point tier answered and its shard carries
|
|
238
|
+
// them (normalized locality key + postcode of the ROOFTOP) — see geocode-core's GeocodeResult.rooftop.
|
|
239
|
+
rooftop: z
|
|
240
|
+
.object({
|
|
241
|
+
localityNorm: z.string().optional(),
|
|
242
|
+
postcode: z.string().optional(),
|
|
243
|
+
})
|
|
244
|
+
.optional(),
|
|
245
|
+
// #42: the country the postcode-country coherence pass scoped the walk to, or null. Non-null ONLY when it
|
|
246
|
+
// OVERRODE the request's country prior — so a caller who asked for US and got an FR answer can see which
|
|
247
|
+
// evidence bought the change instead of reading it as a bug.
|
|
248
|
+
postcode_country_scope: z.string().nullable(),
|
|
249
|
+
// ROAD_TO_V9 §4: query-intent advisories. Always present; empty means the vocabulary looked and had nothing to
|
|
250
|
+
// say. Advisory ONLY — no marker changed which answer won, and a client is free to ignore the array entirely.
|
|
251
|
+
intent_markers: z.array(QueryIntentMarkerSchema),
|
|
252
|
+
// #1717 stage 1: flag-only admin-coherence verdicts — did the winning candidate's resolved ancestry confirm,
|
|
253
|
+
// contradict, or fail to speak to the PARSED region/country qualifiers? Nothing ranks or gates on these; present
|
|
254
|
+
// whenever a winner resolved (both members always populated — `unstated` is the explicit no-qualifier claim),
|
|
255
|
+
// absent when nothing resolved to check against. See mailwoman's `admin-coherence.ts` for the verdict contract.
|
|
256
|
+
admin_coherence: z
|
|
257
|
+
.object({
|
|
258
|
+
region: z.enum(["confirmed", "contradicted", "unstated", "unverifiable"]),
|
|
259
|
+
country: z.enum(["confirmed", "contradicted", "unstated", "unverifiable"]),
|
|
260
|
+
})
|
|
261
|
+
.optional(),
|
|
262
|
+
// #1755: spans the flat `components` map could not represent. `components` holds one value per tag, so a second
|
|
263
|
+
// `locality` span ceases to exist there — and without this line `region: null` means both "the input named no
|
|
264
|
+
// region" and "it named one and we deleted it". Absent when nothing was dropped; never an empty array on the wire,
|
|
265
|
+
// because the common case is nothing dropped and a client should not have to read a field to learn that.
|
|
266
|
+
dropped_components: z
|
|
267
|
+
.array(
|
|
268
|
+
z.object({
|
|
269
|
+
tag: ComponentTagSchema,
|
|
270
|
+
value: z.string(),
|
|
271
|
+
// The value that held the slot, so a reader sees which of the two survived without re-deriving it.
|
|
272
|
+
kept: z.string(),
|
|
273
|
+
})
|
|
274
|
+
)
|
|
275
|
+
.optional(),
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
export type GeocodeOutcomeLike = z.infer<typeof GeocodeOutcomeLikeSchema>
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* `POST /v1/geocode` response — a hand-modeled mirror of `GeocodeResult`'s wire shape (`mailwoman/geocode-core.ts`),
|
|
282
|
+
* `.loose()` so a field the engine adds that this schema doesn't yet know about still rides through undocumented rather
|
|
283
|
+
* than being stripped or rejected. DOC-ACCURACY ONLY: the route passes `engine.geocode()`'s outcome through verbatim
|
|
284
|
+
* (`GeocodeOutcome = Record<string, unknown>`, `api/engine.ts`) — nothing here validates a real response, so a
|
|
285
|
+
* schema/engine mismatch can never reject or mutate a result at runtime. Deliberately carries NO import from
|
|
286
|
+
* `mailwoman` (the engine-agnosticism boundary — `mailwoman` is the one workspace allowed to depend on
|
|
287
|
+
* `@mailwoman/api`, never the reverse). `mailwoman/test/api-schema-drift.test.ts` is the compile-time tripwire that
|
|
288
|
+
* catches this shape drifting from the real `GeocodeResult` interface.
|
|
289
|
+
*/
|
|
290
|
+
export const GeocodeOutcomeSchema = GeocodeOutcomeLikeSchema.loose().openapi("GeocodeOutcome")
|
|
291
|
+
|
|
292
|
+
export type GeocodeOutcome = z.infer<typeof GeocodeOutcomeSchema>
|
|
157
293
|
|
|
158
294
|
/**
|
|
159
295
|
* `POST /v1/batch` request body.
|