@finbheara/domain 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +273 -2
- package/dist/age.js +420 -0
- package/dist/ceili.js +15 -0
- package/dist/chunks/place-7gqecaxd.js +201 -0
- package/dist/chunks/place-g6yw4cq6.js +134 -0
- package/dist/chunks/place-kr42294h.js +267 -0
- package/dist/chunks/place-qbp3xfb4.js +7412 -0
- package/dist/clrg-regions.js +603 -0
- package/dist/dance-types.js +14 -0
- package/dist/dance.js +41 -0
- package/dist/noun-index.js +233 -0
- package/dist/place.js +36 -0
- package/dist/school.js +2269 -0
- package/dist/tune.js +23 -0
- package/dist/types/age-types.d.ts +31 -0
- package/dist/types/age.d.ts +84 -0
- package/dist/types/ceili.d.ts +53 -0
- package/dist/types/clrg-regions.d.ts +31 -0
- package/dist/types/dance-match.d.ts +28 -0
- package/dist/types/dance-types.d.ts +9 -0
- package/dist/types/dance.d.ts +14 -0
- package/dist/types/noun-index.d.ts +67 -0
- package/dist/types/place-tree.d.ts +4 -0
- package/dist/types/place-types.d.ts +24 -0
- package/dist/types/place.d.ts +98 -0
- package/dist/types/school-compare.d.ts +39 -0
- package/dist/types/school-encoder.d.ts +8 -0
- package/dist/types/school-exception-vocabulary.d.ts +23 -0
- package/dist/types/school-fluff-vocabulary.d.ts +16 -0
- package/dist/types/school-fused-prefix.d.ts +1 -0
- package/dist/types/school-identity.d.ts +9 -0
- package/dist/types/school-institution-vocabulary.d.ts +20 -0
- package/dist/types/school-location.d.ts +16 -0
- package/dist/types/school-match-key.d.ts +5 -0
- package/dist/types/school-name.d.ts +82 -0
- package/dist/types/school-tokens.d.ts +20 -0
- package/dist/types/school.d.ts +11 -0
- package/dist/types/tune.d.ts +63 -0
- package/package.json +73 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Finbheara Systems
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,274 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @finbheara/domain
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Places, CLRG regions, school names, ages and dances as they are actually printed: read,
|
|
4
|
+
compared and told apart. A domain model downstream of
|
|
5
|
+
[`@finbheara/names`](https://www.npmjs.com/package/@finbheara/names), which reads the
|
|
6
|
+
person names.
|
|
7
|
+
|
|
8
|
+
- **Places**: a print in, the set of place-tree nodes it may mean out. A country, a state or
|
|
9
|
+
province, a county, a city, or a CLRG region, with a relation between any two readings.
|
|
10
|
+
- **CLRG regions**: the CLRG's dance regions and their Oireachtas codes, and the region a
|
|
11
|
+
location phrase falls in.
|
|
12
|
+
- **School names**: a printed school name in, a `SchoolName` out, with a display form, a
|
|
13
|
+
filing key and a verdict on whether two prints may be one school.
|
|
14
|
+
- **Ages**: an age group (`U12`, `Over 21`, `13-14`) or a year of birth in, an `Age` out: a
|
|
15
|
+
closed range of birth years, exact when it is one year, with `fitsIn`, `overlaps` and a
|
|
16
|
+
team's `averageAge`.
|
|
17
|
+
- **Dances**: a spoken or printed set dance or céilí dance in, the dances it may name out,
|
|
18
|
+
with tempo rules and hand counts.
|
|
19
|
+
- **A database-backed index** of any of these nouns: the slots an in-memory name map hashes
|
|
20
|
+
on, stored as indexed columns of a SQLite table you own.
|
|
21
|
+
|
|
22
|
+
It was built on the stage announcements and results sheets of Irish dance competitions,
|
|
23
|
+
where one school arrives as `Ardvarna Academy of Irish Dance (Calgary, AB)` and as
|
|
24
|
+
`ARDVARNA ACADEMY - Calgary, Alberta`.
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
npm install @finbheara/domain
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
ESM, Node ≥ 22 or Bun ≥ 1.4.2. MIT. Not affiliated with or endorsed by any dance commission.
|
|
31
|
+
|
|
32
|
+
## Entry points
|
|
33
|
+
|
|
34
|
+
| import | what |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `@finbheara/domain/place` | `place()`, `createPlaceReader`, `placeRelation`, the `Place` types |
|
|
37
|
+
| `@finbheara/domain/clrg-regions` | `REGIONS`, `regionOf`, `resolvePhrase` |
|
|
38
|
+
| `@finbheara/domain/school` | `createSchoolNameReader`, `DEFAULT_SCHOOL_NAME_READER`, `compareSchoolNames`, `foldSchoolName`, `schoolEncoder`, `schoolColumns` |
|
|
39
|
+
| `@finbheara/domain/tune` | `tune()`, `tuneRelation`, `tempoLegal`, `settingAt`, `TUNE_NODES`: the CLRG set dance list |
|
|
40
|
+
| `@finbheara/domain/ceili` | `ceili()`, `ceiliRelation`, `CEILI_NODES`: the céilí handbook dances |
|
|
41
|
+
| `@finbheara/domain/dance` | `dance()`: a print read as a tune and a céilí dance, narrowed by its round |
|
|
42
|
+
| `@finbheara/domain/age` | `Age.from`, `Age.fromYOB`, `ageRelation`, `canOverlap`, `averageAge`, `isAveraged`, the age constants |
|
|
43
|
+
| `@finbheara/domain/noun-index` | `createIndex`, `indexSchema`, `personEncoder`, and the `SqlHandle`/`NounEncoder` types: a database-backed name map (below) |
|
|
44
|
+
| `@finbheara/domain/dance-types` | `danceTypes()`, `DANCE_TYPES`, `rhythmOfTime`, and the shared `DanceMatch`/`DanceRelation`/`SetRhythm`/`TimeSignature` types |
|
|
45
|
+
|
|
46
|
+
The library does no I/O and installs nothing process-wide. Every reader has a default and a
|
|
47
|
+
factory: build your own with options when the default is not what you want.
|
|
48
|
+
|
|
49
|
+
## Read a place
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { place, placeRelation } from "@finbheara/domain/place";
|
|
53
|
+
|
|
54
|
+
const p = place("Pennsylvania, Mid-Atlantic Region");
|
|
55
|
+
p.arm; // "resolved"
|
|
56
|
+
p.pl2; // "USA-PA"
|
|
57
|
+
p.nodes.map((n) => n.id); // ["USA", "USA-PA", "IDTANA-MARO"]
|
|
58
|
+
p.country; // "USA"
|
|
59
|
+
p.region; // "IDTANA-MARO"
|
|
60
|
+
|
|
61
|
+
place("Calgary, AB").pl2; // "CAN-AB/calgary"
|
|
62
|
+
place("Co. Cork").region; // "MUNSTER"
|
|
63
|
+
place("CA").arm; // "ambiguous": California or Canada
|
|
64
|
+
place("Dublin").arm; // "ambiguous": Ireland, California or Ohio
|
|
65
|
+
|
|
66
|
+
placeRelation(place("Calgary, AB"), place("Western Canada")); // "contains"
|
|
67
|
+
placeRelation(place("Virginia"), place("Ontario")); // "conflict"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
A `Place` is one of four arms: `resolved`, `plural` (a print naming more than one place),
|
|
71
|
+
`ambiguous` (one print, several readings) and `unread`. Only `conflict` between two readings
|
|
72
|
+
says they cannot be one place; `same`, `contains` and `overlaps` do not.
|
|
73
|
+
|
|
74
|
+
## Find a CLRG region
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { REGIONS, regionOf, resolvePhrase } from "@finbheara/domain/clrg-regions";
|
|
78
|
+
|
|
79
|
+
regionOf("Pennsylvania"); // "MARO"
|
|
80
|
+
REGIONS.MARO; // "Mid-Atlantic Region USA"
|
|
81
|
+
resolvePhrase("Pennsylvania, Mid-Atlantic Region"); // ["MARO"]
|
|
82
|
+
resolvePhrase("Calgary in Western Canada"); // ["WCRO"]
|
|
83
|
+
resolvePhrase("Derry in Ireland"); // ["ULSTER"]
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`resolvePhrase` answers every region a phrase supports, and `[]` where it supports none. A
|
|
87
|
+
token that names places in two regions resolves only with a co-token that settles it.
|
|
88
|
+
|
|
89
|
+
## Read a school name
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
import { DEFAULT_SCHOOL_NAME_READER, compareSchoolNames } from "@finbheara/domain/school";
|
|
93
|
+
|
|
94
|
+
const reader = DEFAULT_SCHOOL_NAME_READER;
|
|
95
|
+
const s = reader.schoolName("Ardvarna Academy of Irish Dance (Calgary, AB)");
|
|
96
|
+
s.sn2; // "Ardvarna Academy (CAN-AB/calgary)" display form
|
|
97
|
+
s.sn3; // "ARDVARNA ACADEMY (CAN-AB/calgary)" filing key
|
|
98
|
+
s.institution; // "academy"
|
|
99
|
+
s.location?.pl3; // "CAN-AB/calgary"
|
|
100
|
+
s.isSchool(); // true
|
|
101
|
+
|
|
102
|
+
reader.schoolName("Scoil Rince Glenmorvan").sn2; // "S.R. Glenmorvan"
|
|
103
|
+
reader.schoolName("N/A").refusal?.reason; // "placeholder"
|
|
104
|
+
|
|
105
|
+
compareSchoolNames("Ardvarna Academy of Irish Dance (Calgary, AB)",
|
|
106
|
+
"ARDVARNA ACADEMY - Calgary, Alberta", reader).verdict; // "CANDIDATE"
|
|
107
|
+
compareSchoolNames("Ardvarna Academy (Calgary, AB)",
|
|
108
|
+
"Ardvarna Academy (Ontario)", reader).verdict; // "BLOCKED"
|
|
109
|
+
compareSchoolNames("Ardvarna Academy",
|
|
110
|
+
"Ardvarna Academy (Ontario)", reader).verdict; // "AMBIGUOUS"
|
|
111
|
+
compareSchoolNames("Ardvarna Academy", "Glenmorvan Academy", reader).verdict; // "DIFFERENT"
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The verdicts: `CANDIDATE` (same name, compatible qualifiers), `BLOCKED` (same name,
|
|
115
|
+
contradicting qualifiers), `AMBIGUOUS` (needs context) and `DIFFERENT`. A verdict is a name
|
|
116
|
+
reading and never an identity: two schools can share a name.
|
|
117
|
+
|
|
118
|
+
`DEFAULT_SCHOOL_NAME_READER` is the generic reader: no lexicon, the shipped registers, frozen.
|
|
119
|
+
Nothing replaces it, and the package holds no other. `compareSchoolNames` and
|
|
120
|
+
`schoolPrintSplit` take the reader as an argument, always, so a print is never read by a
|
|
121
|
+
reader you did not choose. A reader with a lexicon (for the surnames a school is named after),
|
|
122
|
+
your own exception and refusal registers, or your own garbage is one you build:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import { compareSchoolNames, createSchoolNameReader } from "@finbheara/domain/school";
|
|
126
|
+
|
|
127
|
+
const reader = createSchoolNameReader({ lexicon: (key) => myLexicon.get(key) });
|
|
128
|
+
compareSchoolNames("Ardvarna Academy", "Ardvarna Academy (Ontario)", reader);
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Your own garbage
|
|
132
|
+
|
|
133
|
+
The shipped refusals are generic: a card number, keyboard filler, a run with no letter in it,
|
|
134
|
+
the shared placeholder list of `@finbheara/names` (`N/A`, `No School`, ...). What YOUR data
|
|
135
|
+
prints in a school field and is not a school, such as an entry system's own "school not
|
|
136
|
+
listed" instruction, a test entry, or a note typed into the wrong field, is yours to declare.
|
|
137
|
+
Pass it as `garbage`, the same idea as `phraseClassifier(lexicon, { garbageVocab })` in
|
|
138
|
+
`@finbheara/names`:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
import { createSchoolNameReader } from "@finbheara/domain/school";
|
|
142
|
+
|
|
143
|
+
const reader = createSchoolNameReader({
|
|
144
|
+
garbage: [
|
|
145
|
+
{ pattern: "-- my school is not on this list --", kind: "exact", reason: "placeholder" },
|
|
146
|
+
{ pattern: "demo school ", kind: "prefix", reason: "placeholder" },
|
|
147
|
+
{ pattern: "^sister of .+$", kind: "regex", reason: "not-a-name" },
|
|
148
|
+
],
|
|
149
|
+
});
|
|
150
|
+
reader.schoolName("-- My School Is Not On This List --").refusal?.reason; // "placeholder"
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
A row's `pattern` is matched against the print with its case lowered, its accents and
|
|
154
|
+
invisible characters dropped and its whitespace collapsed; a `regex` is compiled with the `i`
|
|
155
|
+
flag and anchors itself. Your rows are asked before the shipped ones and the first match
|
|
156
|
+
answers.
|
|
157
|
+
|
|
158
|
+
## Read an age
|
|
159
|
+
|
|
160
|
+
Ages are as of 1 January of the competition year. An `Age` is a closed range of birth years,
|
|
161
|
+
whatever it was read from, so a year of birth and a one-year age group are the same thing.
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
import { Age, averageAge, canOverlap } from "@finbheara/domain/age";
|
|
165
|
+
|
|
166
|
+
const dancer = Age.fromYOB(2014);
|
|
167
|
+
dancer.isExact(); // true
|
|
168
|
+
|
|
169
|
+
const u12 = Age.from("U12", { year: 2026 });
|
|
170
|
+
u12.birthYears(); // { from: 2014, to: 2023 }
|
|
171
|
+
dancer.fitsIn(u12); // true
|
|
172
|
+
|
|
173
|
+
// With the age groups the event offered, U12 is one birth year, so it is exact.
|
|
174
|
+
const ladder = { caps: [11, 12, 13], overs: [], ranges: [] };
|
|
175
|
+
Age.from("U12", { year: 2026, ladder }).isExact(); // true
|
|
176
|
+
|
|
177
|
+
Age.fromYOB(1999).fitsIn(Age.from("U18", { year: 2025 })); // false
|
|
178
|
+
Age.fromYOB(1999).fitsIn(Age.from("Over 21", { year: 2026, top: true })); // true
|
|
179
|
+
|
|
180
|
+
canOverlap(u12, Age.from("U14", { year: 2026 }), dancer); // true
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
A team's age is the average of its dancers' ages, rounded to two decimals. `averageAge`
|
|
184
|
+
takes exact ages only and returns an `AverageAge`, a separate type that refuses, by name,
|
|
185
|
+
any use that would treat it as one dancer's age.
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
const team = averageAge([1990, 1985, 1999, 1988].map((y) => Age.fromYOB(y)));
|
|
189
|
+
team.meanAge(2026); // 34.5
|
|
190
|
+
team.fitsIn(Age.from("U40", { year: 2026 })); // true
|
|
191
|
+
|
|
192
|
+
averageAge([u12]); // throws AgeMisuseError "not-exact"
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Index names in a database
|
|
196
|
+
|
|
197
|
+
`createNameMap` (from `@finbheara/names`) and `mapOfSchools` hold their names in memory. An
|
|
198
|
+
index holds them in a SQLite table, as columns: every slot those maps hash a name on, indexed,
|
|
199
|
+
so a lookup at a level is one equality and nothing is loaded. It is a multi-map: a row is one
|
|
200
|
+
name and one value, and a lookup answers every value filed at the name's slot.
|
|
201
|
+
|
|
202
|
+
The package depends on no SQLite module. Hand it a handle with `prepare(sql)` returning a
|
|
203
|
+
statement with `run(...)` and `all(...)`: `bun:sqlite`'s `Database` and `node:sqlite`'s
|
|
204
|
+
`DatabaseSync` (Node ≥ 22.5) are both one as they are.
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
import { Database } from "bun:sqlite"; // or: import { DatabaseSync } from "node:sqlite"
|
|
208
|
+
import { createNameMap, personName } from "@finbheara/names/normalize";
|
|
209
|
+
import { createIndex, indexSchema, personEncoder } from "@finbheara/domain/noun-index";
|
|
210
|
+
|
|
211
|
+
const db = new Database(":memory:");
|
|
212
|
+
const people = personEncoder();
|
|
213
|
+
const { table, indexes } = indexSchema(people, { table: "person" });
|
|
214
|
+
db.prepare(table).run();
|
|
215
|
+
for (const s of indexes) db.prepare(s).run(); // after a bulk load, if you have one
|
|
216
|
+
|
|
217
|
+
const index = createIndex(db, people, { table: "person" });
|
|
218
|
+
index.add(personName("Aoife Ní Mhaolvarna"), 1);
|
|
219
|
+
index.add(personName("Mhaolvarna, Aoife Ni"), 2);
|
|
220
|
+
index.add(personName("Kestrel Evbatyrov"), 3);
|
|
221
|
+
|
|
222
|
+
index.get(personName("AOIFE NI MHAOLVARNA"), "nn3"); // [1, 2]
|
|
223
|
+
index.get(personName("Aoife Ní Mhaolvarna"), "nn0"); // [1]
|
|
224
|
+
index.find("surname_key", personName("Aoife Ní Mhaolvarna").surnameKey()); // [1, 2]
|
|
225
|
+
index.nouns(2)[0]?.nn2; // "NiMhaolvarna<<Aoife": the parse, read back
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`map(level)` is the single-valued view, with `createNameMap`'s interface and its answers:
|
|
229
|
+
the first name filed at a slot keeps it, a later `set` replaces the value, and a print that
|
|
230
|
+
names no person is held and never found.
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
const byName = index.map("nn3");
|
|
234
|
+
byName.get(personName("Aoife Ni Mhaolvarna")); // 1
|
|
235
|
+
byName.size; // 2
|
|
236
|
+
createNameMap<number>("nn3", [...byName]).size; // 2
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
The person columns are the print, the reading it was parsed under, and `nameColumns()` from
|
|
240
|
+
`@finbheara/names`; the levels are `nn0`-`nn4`. A school's (`schoolEncoder` from
|
|
241
|
+
`@finbheara/domain/school`) are the print, `sn0`-`sn4`, `realWords` and `matchKey`, and its
|
|
242
|
+
levels are `mapOfSchools`'s. One print may be filed under several schools, and a school under
|
|
243
|
+
many prints:
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
import { DEFAULT_SCHOOL_NAME_READER, schoolEncoder } from "@finbheara/domain/school";
|
|
247
|
+
|
|
248
|
+
const read = (p: string) => DEFAULT_SCHOOL_NAME_READER.schoolName(p);
|
|
249
|
+
const schools = createIndex(db, schoolEncoder(), { table: "school" }); // after its indexSchema
|
|
250
|
+
schools.add(read("Ardvarna Academy"), 10);
|
|
251
|
+
schools.add(read("Ardvarna Academy"), 11);
|
|
252
|
+
schools.add(read("Ardvarna Academy (Ontario)"), 11);
|
|
253
|
+
schools.add(read("ARDVARNA ACADEMY - Calgary, Alberta"), 10);
|
|
254
|
+
|
|
255
|
+
schools.get(read("Ardvarna Academy"), "sn0"); // [10, 11]
|
|
256
|
+
schools.nouns(11).map((s) => s.sn0); // ["Ardvarna Academy", "Ardvarna Academy (Ontario)"]
|
|
257
|
+
schools.get(read("Ardvarna Academy of Irish Dance (Calgary, AB)"), "sn3"); // [10]
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
`rows({ column: keys, ... })` answers every row where any column holds one of its keys, and
|
|
261
|
+
`glob(column, pattern)` a SQLite `GLOB` on one column. An encoder is a plain object: add a
|
|
262
|
+
column of your own by spreading one, `{ ...enc, columns: [...enc.columns, "k"], encode: (x) =>
|
|
263
|
+
({ ...enc.encode(x), k: f(x) }) }`. `indexSchema` takes the table, the value column and its
|
|
264
|
+
type (`{ value: "id", valueType: "INTEGER PRIMARY KEY" }` makes the value the row id); on such a table `map(level)` iterates in value order, and a `set` to a value another row
|
|
265
|
+
holds is refused by the key).
|
|
266
|
+
|
|
267
|
+
## Known gaps
|
|
268
|
+
|
|
269
|
+
- `place()` reads `"Derry in Ireland"` and `"Calgary in Western Canada"` as `unread`: it does
|
|
270
|
+
not split a phrase at `in`. `resolvePhrase` finds their regions.
|
|
271
|
+
|
|
272
|
+
## Licence
|
|
273
|
+
|
|
274
|
+
MIT, Copyright (c) 2026 Finbheara Systems.
|