@mailwoman/geographic-model 10.0.0 → 10.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.
Files changed (62) hide show
  1. package/README.md +32 -32
  2. package/data/geographic-model.json +5 -5
  3. package/data/model/concepts.json +5 -5
  4. package/lib/artifact.ts +37 -65
  5. package/lib/compile.ts +55 -70
  6. package/lib/index.ts +22 -35
  7. package/lib/load.ts +60 -72
  8. package/lib/lookup.ts +12 -28
  9. package/lib/packaged.ts +50 -0
  10. package/lib/schema.ts +104 -97
  11. package/lib/validate.ts +32 -56
  12. package/lib/validation/issues.ts +32 -36
  13. package/lib/validation/references.ts +24 -27
  14. package/out/artifact.d.ts +28 -45
  15. package/out/artifact.d.ts.map +1 -1
  16. package/out/artifact.js +25 -52
  17. package/out/artifact.js.map +1 -1
  18. package/out/compile.d.ts +27 -41
  19. package/out/compile.d.ts.map +1 -1
  20. package/out/compile.js +42 -64
  21. package/out/compile.js.map +1 -1
  22. package/out/index.d.ts +22 -35
  23. package/out/index.d.ts.map +1 -1
  24. package/out/index.js +22 -35
  25. package/out/index.js.map +1 -1
  26. package/out/load.d.ts +30 -39
  27. package/out/load.d.ts.map +1 -1
  28. package/out/load.js +42 -56
  29. package/out/load.js.map +1 -1
  30. package/out/lookup.d.ts +12 -28
  31. package/out/lookup.d.ts.map +1 -1
  32. package/out/lookup.js +4 -16
  33. package/out/lookup.js.map +1 -1
  34. package/out/packaged.d.ts +24 -0
  35. package/out/packaged.d.ts.map +1 -0
  36. package/out/packaged.js +42 -0
  37. package/out/packaged.js.map +1 -0
  38. package/out/schema.d.ts +104 -97
  39. package/out/schema.d.ts.map +1 -1
  40. package/out/schema.js +37 -52
  41. package/out/schema.js.map +1 -1
  42. package/out/tools/build-artifact.d.ts +27 -0
  43. package/out/tools/build-artifact.d.ts.map +1 -0
  44. package/out/tools/build-artifact.js +44 -0
  45. package/out/tools/build-artifact.js.map +1 -0
  46. package/out/validate.d.ts +29 -50
  47. package/out/validate.d.ts.map +1 -1
  48. package/out/validate.js +12 -35
  49. package/out/validate.js.map +1 -1
  50. package/out/validation/issues.d.ts +17 -20
  51. package/out/validation/issues.d.ts.map +1 -1
  52. package/out/validation/issues.js +25 -29
  53. package/out/validation/issues.js.map +1 -1
  54. package/out/validation/references.d.ts.map +1 -1
  55. package/out/validation/references.js +20 -23
  56. package/out/validation/references.js.map +1 -1
  57. package/package.json +35 -76
  58. package/tools/build-artifact.ts +56 -0
  59. package/out/scripts/build-artifact.d.ts +0 -51
  60. package/out/scripts/build-artifact.d.ts.map +0 -1
  61. package/out/scripts/build-artifact.js +0 -85
  62. package/out/scripts/build-artifact.js.map +0 -1
package/lib/load.ts CHANGED
@@ -3,27 +3,17 @@
3
3
  * @license AGPL-3.0
4
4
  * @author Teffen Ellis, et al.
5
5
  *
6
- * The authoring loader: a directory of JSON files in, one {@link GeographicModelDocument} out.
6
+ * Loads a directory of JSON files into one {@link GeographicModelDocument}.
7
7
  *
8
- * **The filesystem layout is authoring convenience and carries no meaning.** A concept means the
9
- * same thing whichever file it was written in, and a file may hold any subset of the tables. What a
10
- * directory does carry is one manifest — `model.json`, holding the document's `version` — because a
11
- * version assembled from whichever fragment happened to declare one is a version nobody chose.
8
+ * File layout does not assign meaning to a file. Any file may hold any subset of the tables.
9
+ * Only `model.json` may declare the document's `version`.
12
10
  *
13
- * Two properties make the loader safe to build an artifact from:
11
+ * The loader sorts files by path before reading them, so directory enumeration order never affects
12
+ * the output. It records the source file of every record and maps each validation issue back to that
13
+ * file. A duplicate-identifier issue also reports the file that used the identifier first.
14
14
  *
15
- * 1. **Enumeration order cannot reach the output.** {@link mergeGeographicModelFiles} sorts the
16
- * files it was handed before reading any of them, so the merged tables are a function of the file
17
- * NAMES and their contents. `readdir` order, and therefore the filesystem, is out of the answer.
18
- * 2. **Every issue names the file it came from.** The document validator addresses a record by its
19
- * position in the merged table (`$.concepts[7].kind`), which is the one thing an author cannot
20
- * see; the loader keeps a per-record origin and re-addresses each issue to its source file. A
21
- * duplicate identifier names both files — the one that claimed it and the one that claimed it
22
- * first — because "already used" is unactionable without the other half.
23
- *
24
- * Validation itself is delegated whole to `./validate.ts`. What the loader checks on its own is only
25
- * what the validator cannot see: whether a file parses, whether it is an object, and whether the
26
- * keys it uses are tables.
15
+ * `./validate.ts` performs document validation. The loader checks only that each file parses, holds
16
+ * an object and uses known table keys.
27
17
  */
28
18
 
29
19
  import { readLocalTextFile } from "@mailwoman/core/fs/readers"
@@ -43,12 +33,13 @@ import {
43
33
  ValidationIssueCode,
44
34
  } from "#validation/issues"
45
35
  /**
46
- * The manifest every model directory carries: the document's `version`, and nothing else.
36
+ * The manifest file that every model directory contains.
37
+ * It holds only the document's `version`.
47
38
  */
48
39
  export const MODEL_MANIFEST_FILENAME = "model.json"
49
40
 
50
41
  /**
51
- * The keys a source file may use. They are the document's tables, minus the manifest's `version`.
42
+ * The table keys that a non-manifest source file may use.
52
43
  */
53
44
  const TABLE_FIELDS = ["relations", "concepts", "mappings", "observations", "derivedFacts"] as const
54
45
 
@@ -57,49 +48,49 @@ type TableField = (typeof TABLE_FIELDS)[number]
57
48
  const MANIFEST_FIELDS = ["version"] as const
58
49
 
59
50
  /**
60
- * The document path a record occupies, e.g. `$.concepts[7]` or `$.concepts[7].assertions[1]`. Group 3 is present only
61
- * for an assertion, which is the one record that nests.
51
+ * Matches a record's path in the merged document, such as `$.concepts[7]` or `$.concepts[7].assertions[1]`.
52
+ * Group 3 matches only for a nested assertion.
62
53
  */
63
54
  const RECORD_PATH_PATTERN = /^\$\.([A-Za-z]+)\[(\d+)\](?:\.assertions\[(\d+)\])?/u
64
55
 
65
56
  /**
66
- * Every way loading can fail. The document validator's whole vocabulary, plus the one failure only a loader meets: a
67
- * file that is not JSON at all.
57
+ * Load issue codes: every validation code plus `MalformedJSON`.
68
58
  */
69
59
  export const LoadIssueCode = {
70
60
  ...ValidationIssueCode,
71
61
  /**
72
- * A source file could not be parsed as JSON. Emitted by the loader alone; the document validator is handed values,
73
- * never text.
62
+ * A source file is not valid JSON.
74
63
  */
75
64
  MalformedJSON: "malformed_json",
76
65
  } as const
77
66
 
67
+ /**
68
+ * A {@link LoadIssueCode} value.
69
+ */
78
70
  export type LoadIssueCode = (typeof LoadIssueCode)[keyof typeof LoadIssueCode]
79
71
 
80
72
  /**
81
- * One violation, addressed to the file an author can open.
73
+ * One load issue, attributed to its source file.
82
74
  */
83
75
  export interface SourcedIssue {
84
76
  /**
85
- * The source file, relative to the model directory, with `/` separators on every platform.
77
+ * The source file path relative to the model directory, with `/` separators on every platform.
86
78
  */
87
79
  file: string
88
80
  /**
89
- * The JSONPath-style address into the MERGED document, kept so a reader can find the record in the table the
90
- * validator saw.
81
+ * The JSONPath-style location in the merged document.
91
82
  */
92
83
  path: string
93
84
  code: LoadIssueCode
94
85
  message: string
95
86
  /**
96
- * For a duplicate identifier: the file that claimed it first.
87
+ * For a duplicate identifier, the file that used the identifier first.
97
88
  */
98
- otherFile?: string
89
+ otherFile: string | null
99
90
  }
100
91
 
101
92
  /**
102
- * One authoring file: its path relative to the model directory, and its text.
93
+ * A source file's path relative to the model directory, plus its text.
103
94
  */
104
95
  export interface GeographicModelSourceFile {
105
96
  path: string
@@ -107,7 +98,7 @@ export interface GeographicModelSourceFile {
107
98
  }
108
99
 
109
100
  /**
110
- * Render every issue as one line, `file:path: message [code]`, in the order the loader produced them.
101
+ * Formats each issue as one `file:path: message [code]` line, in input order.
111
102
  */
112
103
  export function formatSourcedIssues(issues: readonly SourcedIssue[]): string {
113
104
  return issues
@@ -120,8 +111,8 @@ export function formatSourcedIssues(issues: readonly SourcedIssue[]): string {
120
111
  }
121
112
 
122
113
  /**
123
- * Thrown when a model directory does not load. Carries every issue, and states them all in its message, so a caller
124
- * that only prints `error.message` still sees the whole list.
114
+ * The error thrown when a model directory does not load.
115
+ * Its message lists every issue.
125
116
  */
126
117
  export class GeographicModelLoadError extends Error {
127
118
  readonly issues: readonly SourcedIssue[]
@@ -135,16 +126,16 @@ export class GeographicModelLoadError extends Error {
135
126
  }
136
127
 
137
128
  /**
138
- * Where one record came from, kept so a validation issue addressed to the merged table can be re-addressed to a file.
129
+ * The source of one record in the merged document.
139
130
  */
140
131
  interface RecordOrigin {
141
132
  file: string
142
133
  /**
143
- * The table the record was appended to, or `assertions` for one nested inside a concept — the namespace its
144
- * identifier is unique within.
134
+ * The record's table, or `assertions` for an assertion nested in a concept.
135
+ * Identifiers are unique within a table.
145
136
  */
146
137
  table: string
147
- id?: string
138
+ id: string | null
148
139
  }
149
140
 
150
141
  interface MergeState {
@@ -152,23 +143,21 @@ interface MergeState {
152
143
  tables: Record<TableField, unknown[]>
153
144
  origins: Map<string, RecordOrigin>
154
145
  /**
155
- * Table → identifier → the file that used it first. The validator reports the SECOND claimant, so this is what names
156
- * the other half of the pair.
146
+ * Maps each table to its identifiers and the file that used each identifier first.
147
+ * The validator reports only the second use.
157
148
  */
158
149
  firstClaims: Map<string, Map<string, string>>
159
- version?: string
150
+ version: string | null
160
151
  }
161
152
 
162
153
  function sourced(file: string, issues: readonly ValidationIssue[]): SourcedIssue[] {
163
- return issues.map((issue) => ({ file, path: issue.path, code: issue.code, message: issue.message }))
154
+ return issues.map((issue) => ({ file, path: issue.path, code: issue.code, message: issue.message, otherFile: null }))
164
155
  }
165
156
 
166
157
  /**
167
- * Parse one source file, or report why it could not be parsed.
158
+ * Parses one source file, or records the parse failure and returns `undefined`.
168
159
  *
169
- * The house wrapper lives in `@mailwoman/core/objects`, and this package takes no dependency on `@mailwoman/core` — the
170
- * boundary record keeps world semantics out of core, and a build-time loader is not the reason to reverse it. The
171
- * parser's own message is also the useful half of the report here, which a wrapper returning a fallback discards.
160
+ * The loader calls `JSON.parse` directly because the report needs the parser's error message.
172
161
  */
173
162
  function readSourceJSON(file: GeographicModelSourceFile, issues: SourcedIssue[]): unknown {
174
163
  try {
@@ -180,14 +169,15 @@ function readSourceJSON(file: GeographicModelSourceFile, issues: SourcedIssue[])
180
169
  path: "$",
181
170
  code: LoadIssueCode.MalformedJSON,
182
171
  message: error instanceof Error ? error.message : String(error),
172
+ otherFile: null,
183
173
  })
184
174
 
185
175
  return undefined
186
176
  }
187
177
  }
188
178
 
189
- function claim(state: MergeState, table: string, id: string | undefined, file: string): void {
190
- if (id === undefined) return
179
+ function claim(state: MergeState, table: string, id: string | null, file: string): void {
180
+ if (!id) return
191
181
 
192
182
  const claims = state.firstClaims.get(table) ?? new Map<string, string>()
193
183
 
@@ -198,10 +188,10 @@ function claim(state: MergeState, table: string, id: string | undefined, file: s
198
188
  }
199
189
  }
200
190
 
201
- function recordID(entry: unknown): string | undefined {
202
- if (!isPlainObject(entry)) return undefined
191
+ function recordID(entry: unknown): string | null {
192
+ if (!isPlainObject(entry)) return null
203
193
 
204
- return typeof entry.id === "string" ? entry.id : undefined
194
+ return typeof entry.id === "string" ? entry.id : null
205
195
  }
206
196
 
207
197
  function readManifestFile(state: MergeState, file: GeographicModelSourceFile, value: unknown): void {
@@ -219,7 +209,7 @@ function readManifestFile(state: MergeState, file: GeographicModelSourceFile, va
219
209
  }
220
210
 
221
211
  /**
222
- * Append one file's tables to the merged document, recording where every record came from.
212
+ * Appends one file's tables to the merged document and records each record's origin.
223
213
  */
224
214
  function readTableFile(state: MergeState, file: GeographicModelSourceFile, value: unknown): void {
225
215
  const issues: ValidationIssue[] = []
@@ -232,8 +222,7 @@ function readTableFile(state: MergeState, file: GeographicModelSourceFile, value
232
222
  return
233
223
  }
234
224
 
235
- // `version` is admitted to the field check and then refused on its own, so the report names where a version belongs
236
- // instead of only saying the field is unknown here.
225
+ // The field check allows `version` so that the specific error below can say where it belongs.
237
226
  checkFieldNames(issues, "$", value, [...TABLE_FIELDS, ...MANIFEST_FIELDS])
238
227
 
239
228
  if ("version" in value) {
@@ -277,7 +266,7 @@ function readTableFile(state: MergeState, file: GeographicModelSourceFile, value
277
266
  }
278
267
 
279
268
  /**
280
- * Re-address one validation issue from its position in the merged document to the file the record was authored in.
269
+ * Attributes one validation issue to the file that contains the record.
281
270
  */
282
271
  function attribute(state: MergeState, issue: ValidationIssue): SourcedIssue {
283
272
  const match = RECORD_PATH_PATTERN.exec(issue.path)
@@ -285,30 +274,29 @@ function attribute(state: MergeState, issue: ValidationIssue): SourcedIssue {
285
274
  const key = match ? `${match[1]}[${match[2]}]${nested ? `.assertions[${nested}]` : ""}` : undefined
286
275
  const origin = key ? state.origins.get(key) : undefined
287
276
 
288
- // A document-level issue — `$.version`, or the root itself — is about the manifest, which is the only file that
289
- // contributes anything outside a table.
277
+ // Only the manifest contributes fields outside the tables, so it owns document-level issues.
290
278
  const file = origin?.file ?? MODEL_MANIFEST_FILENAME
291
279
 
292
280
  const claimant =
293
281
  issue.code === ValidationIssueCode.DuplicateID && origin?.id
294
282
  ? state.firstClaims.get(origin.table)?.get(origin.id)
295
- : undefined
283
+ : null
296
284
 
297
285
  return {
298
286
  file,
299
287
  path: issue.path,
300
288
  code: issue.code,
301
289
  message: issue.message,
302
- ...(claimant ? { otherFile: claimant } : {}),
290
+ otherFile: claimant ?? null,
303
291
  }
304
292
  }
305
293
 
306
294
  /**
307
- * Merge authoring files into one document, and validate the result.
295
+ * Merges source files into one document and validates it.
296
+ *
297
+ * The function sorts files by path first, so the input order never affects the result.
308
298
  *
309
- * The files are sorted by path before anything is read, so any enumeration order produces the same tables in the same
310
- * order. Throws {@link GeographicModelLoadError} with every issue, each addressed to its source file; returns nothing
311
- * partial.
299
+ * @throws {GeographicModelLoadError} With every issue, each attributed to its source file.
312
300
  */
313
301
  export function mergeGeographicModelFiles(files: readonly GeographicModelSourceFile[]): GeographicModelDocument {
314
302
  const state: MergeState = {
@@ -316,6 +304,7 @@ export function mergeGeographicModelFiles(files: readonly GeographicModelSourceF
316
304
  tables: { relations: [], concepts: [], mappings: [], observations: [], derivedFacts: [] },
317
305
  origins: new Map(),
318
306
  firstClaims: new Map(),
307
+ version: null,
319
308
  }
320
309
 
321
310
  const ordered = files.toSorted((left, right) => compareIdentifiers(left.path, right.path))
@@ -327,6 +316,7 @@ export function mergeGeographicModelFiles(files: readonly GeographicModelSourceF
327
316
  path: "$",
328
317
  code: LoadIssueCode.MissingField,
329
318
  message: `a model directory carries \`${MODEL_MANIFEST_FILENAME}\`, holding the document's \`version\``,
319
+ otherFile: null,
330
320
  })
331
321
  }
332
322
 
@@ -344,7 +334,7 @@ export function mergeGeographicModelFiles(files: readonly GeographicModelSourceF
344
334
  readTableFile(state, file, value)
345
335
  }
346
336
 
347
- // A record issue cannot be addressed to a file that failed to parse, so the structural pass reports alone.
337
+ // Validation issues cannot be attributed reliably after a structural failure, so those failures stop here.
348
338
  if (state.issues.length) throw new GeographicModelLoadError(state.issues)
349
339
 
350
340
  const result = validateGeographicModelDocument({ version: state.version, ...state.tables })
@@ -357,11 +347,9 @@ export function mergeGeographicModelFiles(files: readonly GeographicModelSourceF
357
347
  }
358
348
 
359
349
  /**
360
- * Every `*.json` file under `root`, relative to it, in code-point order.
350
+ * Lists every `*.json` file under `root` as a relative path, sorted by code point at each level.
361
351
  *
362
- * Directory entries are sorted at each level rather than taken as `readdir` returns them, so the list is a property of
363
- * the tree and not of the filesystem that stored it. Symbolic links are not followed: a model directory is source, and
364
- * a link out of it is a record whose home nobody can state.
352
+ * Symbolic links are skipped because only regular files and directories match.
365
353
  */
366
354
  async function listSourceFiles(root: string, prefix = ""): Promise<string[]> {
367
355
  const entries = await Globerator.from("*", {
@@ -390,9 +378,9 @@ async function listSourceFiles(root: string, prefix = ""): Promise<string[]> {
390
378
  }
391
379
 
392
380
  /**
393
- * Load a model directory: read every `*.json` file under it, merge them, and validate the result.
381
+ * Reads every `*.json` file under `root`, merges the files and validates the merged document.
394
382
  *
395
- * Throws {@link GeographicModelLoadError} with every issue, each addressed to its source file.
383
+ * @throws {GeographicModelLoadError} With every issue, each attributed to its source file.
396
384
  */
397
385
  export async function loadGeographicModelDirectory(root: string): Promise<GeographicModelDocument> {
398
386
  const paths = await listSourceFiles(root)
package/lib/lookup.ts CHANGED
@@ -3,19 +3,8 @@
3
3
  * @license AGPL-3.0
4
4
  * @author Teffen Ellis, et al.
5
5
  *
6
- * The read surface over a compiled artifact: every question answered by one map probe, none by
7
- * walking the graph.
8
- *
9
- * The shape is deliberate. `@mailwoman/geographic-model` compiles authored records into an artifact
10
- * precisely so a runtime consumer never traverses anything — so the index exposes lookups
11
- * (`concept`, `ancestorsOf`, `derivedFactsAbout`, `conceptsForExternalID`) and no walk, no cursor,
12
- * and no query language.
13
- *
14
- * Two absences are distinguished everywhere, because they are different answers. A lookup for a
15
- * concept the artifact does not carry returns `undefined` — the model cannot speak to it. A lookup
16
- * for a concept it does carry, which nothing was derived about, returns an empty list — the model
17
- * carries it and states nothing. A reader that collapsed the two would report "no ancestors" for a
18
- * concept it had never heard of.
6
+ * Read-only lookups over a compiled artifact. Queries use prebuilt maps rather than traversing the graph.
7
+ * The lookup returns `undefined` for unknown concepts and an empty list for known concepts with no related facts.
19
8
  */
20
9
 
21
10
  import { compareByCodePoint as compareIdentifiers } from "@mailwoman/core/strings/compare"
@@ -32,36 +21,32 @@ import type {
32
21
  } from "#schema"
33
22
 
34
23
  /**
35
- * Lookups over one compiled artifact.
24
+ * Lookup methods for one compiled artifact.
36
25
  */
37
26
  export interface GeographicModelIndex {
38
27
  /**
39
- * The artifact these lookups read, so a consumer holding the index never has to carry both.
28
+ * Compiled artifact used by this index.
40
29
  */
41
30
  readonly model: CompiledGeographicModel
42
31
  /**
43
- * One concept record, or `undefined` when the artifact does not carry it.
32
+ * Concept record, or `undefined` if absent.
44
33
  */
45
34
  concept(id: ConceptID): ConceptRecord | undefined
46
35
  /**
47
- * One relation definition, or `undefined` when the artifact does not carry it.
36
+ * Relation definition, or `undefined` if absent.
48
37
  */
49
38
  relation(id: RelationID): RelationRecord | undefined
50
39
  /**
51
- * Every concept this one is a kind of, transitively, in code-point order. Empty for a concept that is a kind of
52
- * nothing; `undefined` for a concept the artifact does not carry.
40
+ * Transitive ancestors in code-point order; `undefined` if the concept is absent.
53
41
  */
54
42
  ancestorsOf(id: ConceptID): readonly ConceptID[] | undefined
55
43
  /**
56
- * Every derived fact whose subject is this concept, in artifact order. Empty for a concept nothing was derived about;
57
- * `undefined` for a concept the artifact does not carry.
44
+ * Derived facts in artifact order; `undefined` if the concept is absent.
58
45
  */
59
46
  derivedFactsAbout(id: ConceptID): readonly DerivedFactRecord[] | undefined
60
47
  /**
61
- * The concepts a mapping translates this external identifier into, in code-point order.
62
- *
63
- * An empty list is a true negative rather than an unread answer: the artifact carries every mapping the document
64
- * authored, so nothing having declared this identifier is the whole of what there is to know about it.
48
+ * Concepts mapped from this external ID, in code-point order.
49
+ * An empty list means no mapping is recorded.
65
50
  */
66
51
  conceptsForExternalID(vocabulary: ExternalVocabulary, externalID: POICategoryID): readonly ConceptID[]
67
52
  }
@@ -71,10 +56,9 @@ function externalKey(vocabulary: ExternalVocabulary, externalID: string): string
71
56
  }
72
57
 
73
58
  /**
74
- * Index a compiled artifact for reading.
59
+ * Build lookup maps over a compiled artifact.
75
60
  *
76
- * Every table is walked once here so that no table is ever walked again. Nothing is copied — the records handed back
77
- * are the artifact's own.
61
+ * Return references to the artifact's records without copying them.
78
62
  */
79
63
  export function createGeographicModelIndex(model: CompiledGeographicModel): GeographicModelIndex {
80
64
  const concepts = new Map<string, ConceptRecord>(model.concepts.map((concept) => [String(concept.id), concept]))
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ * @file Reads the compiled model this package ships in `data/geographic-model.json`.
6
+ */
7
+
8
+ import { readLocalTextFile } from "@mailwoman/core/fs/readers"
9
+ import { pathExists } from "@mailwoman/core/fs/readers/stat"
10
+ import { resolvePackagePath } from "@mailwoman/core/module/resolvers"
11
+ import { resolvePath } from "path-ts"
12
+
13
+ import { type CompiledGeographicModel, parseCompiledGeographicModel } from "#artifact"
14
+
15
+ /**
16
+ * The authoring directory and the artifact it compiles to.
17
+ *
18
+ * Probes for `model/model.json`, so a genuinely missing `data/` throws naming the path.
19
+ */
20
+ export async function packagedModelPaths(): Promise<{ source: string; artifact: string }> {
21
+ const candidates = [resolvePackagePath("@mailwoman/geographic-model", "data")]
22
+ const probes: Array<[string, boolean]> = []
23
+
24
+ for (const candidate of candidates) {
25
+ probes.push([candidate, await pathExists(resolvePath(candidate, "model/model.json"))])
26
+ }
27
+
28
+ const found = probes.find(([, exists]) => exists)?.[0]
29
+
30
+ if (!found) {
31
+ throw new Error(`geographic-model: could not find data/model — looked in ${candidates.join(", ")}`)
32
+ }
33
+
34
+ return { source: resolvePath(found, "model"), artifact: resolvePath(found, "geographic-model.json") }
35
+ }
36
+
37
+ /**
38
+ * Read the committed artifact.
39
+ *
40
+ * The reader checks the format version.
41
+ * It trusts the records because the loader validated them on input.
42
+ */
43
+ export async function readCompiledGeographicModel(): Promise<CompiledGeographicModel> {
44
+ const text = await readLocalTextFile((await packagedModelPaths()).artifact)
45
+
46
+ // A corrupt committed artifact is a broken build and the `SyntaxError` names the offset.
47
+ // The package's parse wrappers live in `@mailwoman/core`, which this package deliberately does not depend on.
48
+ // oxlint-disable-next-line no-restricted-properties -- see the note above.
49
+ return parseCompiledGeographicModel(JSON.parse(text))
50
+ }