@mailwoman/api 9.1.0 → 9.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/schema.ts DELETED
@@ -1,328 +0,0 @@
1
- /**
2
- * @copyright Sister Software
3
- * @license AGPL-3.0
4
- * @author Teffen Ellis, et al.
5
- *
6
- * Zod wire schemas for the native `/v1` surface. Unlike the drop-ins (photon, nominatim,
7
- * libpostal), nothing here is a vendor contract — this surface is ours to design, so request
8
- * bodies are REQUIRED and validator-enforced (no legacy tolerance to preserve). A `defaultHook`
9
- * on the app maps validation failures through the shared `APIErrorSchema` envelope
10
- * (`apiError(c, 400, "invalid request body", <zod summary>)`) — the pattern boundary every
11
- * surface holds to: where no legacy contract exists, the validator MAY speak, but only in
12
- * our envelope.
13
- *
14
- * `APIErrorSchema` itself is owned by `@mailwoman/api-kit` (plumbing shared by every native
15
- * surface) — it's re-exported here so route modules can import every schema they need, request
16
- * and error alike, from this one file.
17
- */
18
-
19
- import { z } from "@hono/zod-openapi"
20
-
21
- export { APIErrorSchema } from "@mailwoman/api-kit"
22
-
23
- /**
24
- * `POST /v1/parse` request body.
25
- */
26
- /**
27
- * The input register (Decision A / GTM B10): `fragmented` = the map-search register (evidence-bundle channels feed);
28
- * `formatted` = the validation/record register (channels off). Unset → the engine derives it from the input's shape.
29
- * `/v1/batch` defaults to `formatted` (batch rows are the record register by nature).
30
- */
31
- export const InputModeSchema = z.enum(["fragmented", "formatted"]).openapi("InputMode")
32
-
33
- /**
34
- * Longest accepted `address`, in characters.
35
- *
36
- * Sized against what the model can actually read, not against a guess at abuse. The classifier's window is 128
37
- * SentencePiece pieces — roughly 330 characters of address text — and everything past it is truncated before inference,
38
- * so input beyond this bound cannot influence a result. The margin over that window leaves room for scripts that
39
- * tokenize denser than Latin, and for the department-and-division prefixes web forms concatenate.
40
- *
41
- * The bound exists because preprocessing is linear but not free: a 1 MB body costs ~1.7 s across normalize, query-shape
42
- * and the phrase grouper, and Node runs them on the one thread every other request is waiting on. A cap here is cheaper
43
- * than fairness plumbing, and rejecting is more honest than accepting a body whose tail the parser will silently
44
- * discard.
45
- */
46
- export const MAX_ADDRESS_LENGTH = 1024
47
-
48
- /**
49
- * `POST /v1/parse` request body.
50
- */
51
- export const ParseRequestSchema = z
52
- .object({
53
- address: z.string().max(MAX_ADDRESS_LENGTH),
54
- debug: z.boolean().optional(),
55
- input_mode: InputModeSchema.optional(),
56
- })
57
- .openapi("ParseRequest")
58
-
59
- /**
60
- * One `ParseOutcome.components` entry — mirrors {@linkcode ParseComponent} (`engine.ts`).
61
- */
62
- export const ParseComponentSchema = z.object({ tag: z.string(), value: z.string() }).openapi("ParseComponent")
63
-
64
- /**
65
- * `POST /v1/parse` response — mirrors {@linkcode ParseOutcome} (`engine.ts`): the ordered components plus the full
66
- * decoded tree. `tree` is the same loose-tree idiom {@link ResolveResponseSchema} uses (`api/schema.ts:134-146`) — the
67
- * decoder's `AddressTree` is the engine's contract, not this wire schema's.
68
- */
69
- export const ParseOutcomeSchema = z
70
- .object({
71
- input: z.string(),
72
- components: z.array(ParseComponentSchema),
73
- tree: z.looseObject({ roots: z.array(z.unknown()) }),
74
- debug: z.string().optional(),
75
- })
76
- .openapi("ParseOutcome")
77
-
78
- /**
79
- * `POST /v1/geocode` request body.
80
- */
81
- export const GeocodeRequestSchema = z
82
- .object({
83
- address: z.string().max(MAX_ADDRESS_LENGTH),
84
- input_mode: InputModeSchema.optional(),
85
- })
86
- .openapi("GeocodeRequest")
87
-
88
- /**
89
- * One `GeocodeOutcome.hierarchy` entry — locality → country, most specific first. `name` is the resolved gazetteer name
90
- * (proper-cased canonical); `value` is the raw parsed span. Mirrors `GeocodeResult["hierarchy"]` entries
91
- * (`mailwoman/geocode-core.ts`), hand-modeled — see {@link GeocodeOutcomeSchema} for the no-import rationale.
92
- */
93
- const GeocodeHierarchyEntrySchema = z
94
- .object({
95
- tag: z.string(),
96
- value: z.string(),
97
- name: z.string(),
98
- lat: z.number().optional(),
99
- lon: z.number().optional(),
100
- placeID: z.string().optional(),
101
- })
102
- .openapi("GeocodeHierarchyEntry")
103
-
104
- /**
105
- * One `GeocodeOutcome.candidates` entry — a ranked alternative place for the query's primary result (the winning place
106
- * first, then same-query runner-ups). Mirrors `GeocodeResult["candidates"]` entries.
107
- */
108
- const GeocodeCandidateSchema = z
109
- .object({
110
- name: z.string(),
111
- tag: z.string(),
112
- lat: z.number(),
113
- lon: z.number(),
114
- countryCode: z.string().nullable(),
115
- placeID: z.string().optional(),
116
- })
117
- .openapi("GeocodeCandidate")
118
-
119
- /**
120
- * Canonical parsed-component map carried by `GeocodeResult.components`. Spelled out at this engine-agnostic API
121
- * boundary for the same reason the result schema is hand-modeled; the compile-time drift pin in
122
- * `mailwoman/test/api-schema-drift.test.ts` catches any mismatch with the real `ComponentTag`-keyed result type.
123
- */
124
- const GeocodeComponentsSchema = z.partialRecord(
125
- z.enum([
126
- "country",
127
- "region",
128
- "locality",
129
- "dependent_locality",
130
- "postcode",
131
- "subregion",
132
- "house_number",
133
- "street",
134
- "street_prefix",
135
- "street_prefix_particle",
136
- "street_suffix",
137
- "intersection_a",
138
- "intersection_b",
139
- "unit",
140
- "venue",
141
- "attention",
142
- "po_box",
143
- "cedex",
144
- "prefecture",
145
- "municipality",
146
- "district",
147
- "block",
148
- "sub_block",
149
- "building_number",
150
- "building_name",
151
- ]),
152
- z.string()
153
- )
154
-
155
- /**
156
- * One `GeocodeOutcome.intent_markers` entry — an advisory the ROAD_TO_V9 §4 intent vocabulary raised about the QUERY.
157
- * Mirrors `QueryIntentMarker` (`core/pipeline/types.ts`).
158
- *
159
- * `evidence` is deliberately open (`z.record`): each `code` carries its own measurement — a dominance margin, a pair of
160
- * interpretations, a taxonomy id — and flattening those into one closed shape would either lose the numbers or invent
161
- * fields that do not apply. `code` is the discriminator a client branches on.
162
- */
163
- const QueryIntentMarkerSchema = z
164
- .object({
165
- // Spelled out rather than `z.string()` so `mailwoman/test/api-schema-drift.test.ts`'s schema-too-wide direction
166
- // keeps biting: a new `QueryKind` that never reaches this list is a documented contract that has quietly stopped
167
- // describing the real one.
168
- kind: z.enum([
169
- "postcode_only",
170
- "locality_only",
171
- "structured_address",
172
- "intersection",
173
- "po_box",
174
- "landmark",
175
- "poi_query",
176
- "vague",
177
- "bare_toponym",
178
- "route_pair",
179
- "near_me",
180
- "poi_category",
181
- ]),
182
- code: z.enum(["declared_ambiguity", "declared_fork", "focus_point_required", "poi_category"]),
183
- mechanism: z.string(),
184
- message: z.string(),
185
- evidence: z.record(z.string(), z.unknown()).optional(),
186
- })
187
- .openapi("QueryIntentMarker")
188
-
189
- /**
190
- * `POST /v1/geocode` response — a hand-modeled mirror of `GeocodeResult`'s wire shape (`mailwoman/geocode-core.ts`),
191
- * `.loose()` so a field the engine adds that this schema doesn't yet know about still rides through undocumented rather
192
- * than being stripped or rejected. DOC-ACCURACY ONLY: the route passes `engine.geocode()`'s outcome through verbatim
193
- * (`GeocodeOutcome = Record<string, unknown>`, `api/engine.ts`) — nothing here validates a real response, so a
194
- * schema/engine mismatch can never reject or mutate a result at runtime. Deliberately carries NO import from
195
- * `mailwoman` (the engine-agnosticism boundary — `mailwoman` is the one workspace allowed to depend on
196
- * `@mailwoman/api`, never the reverse). `mailwoman/test/api-schema-drift.test.ts` is the compile-time tripwire that
197
- * catches this shape drifting from the real `GeocodeResult` interface.
198
- */
199
- export const GeocodeOutcomeSchema = z
200
- .object({
201
- input: z.string(),
202
- components: GeocodeComponentsSchema,
203
- lat: z.number().nullable(),
204
- lon: z.number().nullable(),
205
- resolution_tier: z.enum(["address_point", "interpolated", "street", "admin"]),
206
- uncertainty_m: z.number().nullable(),
207
- locality: z.string().nullable(),
208
- region: z.string().nullable(),
209
- postcode: z.string().nullable(),
210
- house_number: z.string().nullable(),
211
- street: z.string().nullable(),
212
- // The parsed venue span (#1041 posture; surfaced 2026-08-01 for the hierarchy-evidence campaign R1).
213
- venue: z.string().nullable(),
214
- // The parsed dependent-locality span (parse view; `hierarchy` is the resolved view).
215
- dependent_locality: z.string().nullable(),
216
- // The parsed unit / sub-venue span (parse view) — "Terminal 5", "Suite 300".
217
- unit: z.string().nullable(),
218
- countryCode: z.string().nullable(),
219
- hierarchy: z.array(GeocodeHierarchyEntrySchema),
220
- candidates: z.array(GeocodeCandidateSchema),
221
- // #42: the country the postcode-country coherence pass scoped the walk to, or null. Non-null ONLY when it
222
- // OVERRODE the request's country prior — so a caller who asked for US and got an FR answer can see which
223
- // evidence bought the change instead of reading it as a bug.
224
- postcode_country_scope: z.string().nullable(),
225
- // ROAD_TO_V9 §4: query-intent advisories. Always present; empty means the vocabulary looked and had nothing to
226
- // say. Advisory ONLY — no marker changed which answer won, and a client is free to ignore the array entirely.
227
- intent_markers: z.array(QueryIntentMarkerSchema),
228
- })
229
- .loose()
230
- .openapi("GeocodeOutcome")
231
-
232
- /**
233
- * `POST /v1/batch` request body.
234
- */
235
- export const BatchRequestSchema = z
236
- .object({
237
- // Per-ROW, not just per-request: the row cap (`batchMax`, default 500) bounds how many addresses arrive,
238
- // and this bounds how large each may be. Without both, one request is 500 unbounded bodies.
239
- addresses: z.array(z.string().max(MAX_ADDRESS_LENGTH)),
240
- /**
241
- * Register override for every row. DEFAULT `"formatted"` — batch rows are the record register by nature.
242
- */
243
- input_mode: InputModeSchema.optional(),
244
- })
245
- .openapi("BatchRequest")
246
-
247
- /**
248
- * The failure slot for one batch row. A row that throws does not fail its neighbours.
249
- */
250
- const BatchRowErrorSchema = z.object({ input: z.string(), error: z.string() })
251
-
252
- /**
253
- * One batch row: the geocode outcome, or the failure slot that stands in for it.
254
- */
255
- const BatchRowSchema = z.union([GeocodeOutcomeSchema, BatchRowErrorSchema])
256
-
257
- /**
258
- * `POST /v1/batch` response — one `GeocodeOutcome`, or an `{ input, error }` slot, per row (per-row isolation).
259
- */
260
- export const BatchResponseSchema = z
261
- .object({
262
- results: z.array(BatchRowSchema),
263
- })
264
- .openapi("BatchResponse")
265
-
266
- /**
267
- * `POST /v1/resolve` request body — an already-decoded `AddressTree` (the parser's output) to resolve against the
268
- * gazetteer.
269
- */
270
- export const ResolveRequestSchema = z
271
- .object({
272
- tree: z.looseObject({ roots: z.array(z.unknown()) }),
273
- opts: z.looseObject({}).optional(),
274
- })
275
- .openapi("ResolveRequest")
276
-
277
- /**
278
- * `POST /v1/resolve` response — the same tree, decorated in place with gazetteer coords + attribution.
279
- */
280
- export const ResolveResponseSchema = z
281
- .object({
282
- tree: z.looseObject({ roots: z.array(z.unknown()) }),
283
- })
284
- .openapi("ResolveResponse")
285
-
286
- /**
287
- * One component's value. Repeatable tags (a street with two names, say) arrive as an array; the caller joins them
288
- * before handing the dict to `formatAddress`, which takes single strings only.
289
- */
290
- const ComponentValueSchema = z.union([z.string(), z.array(z.string())])
291
-
292
- /**
293
- * `POST /v1/format` request body. `components` accepts `string | string[]` per key on the wire — a handler-side
294
- * concern, not this schema's: `@mailwoman/formatter`'s `ComponentDict` (`format.ts`) is `Partial<Record<ComponentTag,
295
- * string>>`, single-string only, so a route handler must join array values before calling
296
- * `formatAddress`/`canonicalKey`.
297
- */
298
- export const FormatRequestSchema = z
299
- .object({
300
- components: z.record(z.string(), ComponentValueSchema),
301
- country: z.string(),
302
- options: z.looseObject({}).optional(),
303
- })
304
- .openapi("FormatRequest")
305
-
306
- /**
307
- * `POST /v1/format` response — the rendered string plus the deterministic canonical match key.
308
- */
309
- export const FormatResponseSchema = z
310
- .object({
311
- formatted: z.string(),
312
- canonicalKey: z.string(),
313
- })
314
- .openapi("FormatResponse")
315
-
316
- /**
317
- * `GET /health` response — `status`/`uptime_s` are stamped by the ROUTE itself, unconditionally, regardless of engine
318
- * (`api/routes.ts`'s `healthRoute` handler: `{ status: "ok", uptime_s, ...engine.health?.() }`), so those two are cheap
319
- * + accurate to pin. Everything else is `HealthData` (`api/engine.ts`) — an engine-defined block (model card, data-root
320
- * inventory for `mailwoman serve`; something else entirely for another engine) — stays loose.
321
- */
322
- export const HealthResponseSchema = z
323
- .object({
324
- status: z.literal("ok"),
325
- uptime_s: z.number(),
326
- })
327
- .loose()
328
- .openapi("HealthResponse")