@finbheara/archive-client 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,2357 @@
1
+ // @bun
2
+ import {
3
+ cacheRoot2,
4
+ readConfig2,
5
+ writeJsonAtomic2,
6
+ writeConfig2,
7
+ repoOf2,
8
+ requireRepo2
9
+ } from "./index-hne1wadw.js";
10
+ import {
11
+ parseArchiveTag2,
12
+ compareTagsNewestFirst2,
13
+ parseReleaseSpec2,
14
+ pickTag2
15
+ } from "./index-hghdy980.js";
16
+
17
+ var DATABASES2 = ["results", "majors"];
18
+ var isDatabase = (name) => DATABASES2.includes(name);
19
+ function planAsset2(name, manifest) {
20
+ if (isDatabase(name)) {
21
+ const file = manifest?.databases?.[name]?.file ?? `${name}.sqlite`;
22
+ return {
23
+ asked: name,
24
+ published: [`${file}.zst`, file],
25
+ local: file,
26
+ decompress: true,
27
+ contentSha256: manifest?.databases?.[name]?.sha256 ?? null
28
+ };
29
+ }
30
+ return {
31
+ asked: name,
32
+ published: [name],
33
+ local: name,
34
+ decompress: false,
35
+ contentSha256: manifest?.parquet?.files?.[name]?.sha256 ?? null
36
+ };
37
+ }
38
+
39
+ import { createHash } from "crypto";
40
+ import { createReadStream, createWriteStream } from "fs";
41
+ import { Transform } from "stream";
42
+ import { pipeline } from "stream/promises";
43
+ import { createZstdDecompress } from "zlib";
44
+ async function sha256File2(path) {
45
+ const h = createHash("sha256");
46
+ await pipeline(createReadStream(path), async (source) => {
47
+ for await (const chunk of source)
48
+ h.update(chunk);
49
+ });
50
+ return h.digest("hex");
51
+ }
52
+ async function unzstdTo2(src, dest) {
53
+ const h = createHash("sha256");
54
+ const tap = new Transform({
55
+ transform(chunk, _enc, cb) {
56
+ h.update(chunk);
57
+ cb(null, chunk);
58
+ }
59
+ });
60
+ await pipeline(createReadStream(src), createZstdDecompress(), tap, createWriteStream(dest));
61
+ return h.digest("hex");
62
+ }
63
+ function sha256OfDigest(digest) {
64
+ if (typeof digest !== "string")
65
+ return null;
66
+ const m = /^sha256:([0-9a-f]{64})$/i.exec(digest.trim());
67
+ return m === null ? null : m[1].toLowerCase();
68
+ }
69
+
70
+ import { Age } from "@finbheara/domain/age";
71
+ import { createIndex, indexSchema, personEncoder } from "@finbheara/domain/noun-index";
72
+ import domainPkg from "@finbheara/domain/package.json";
73
+ import { dancerName } from "@finbheara/domain/person";
74
+ import {
75
+ foldNameKey,
76
+ surnameBlock
77
+ } from "@finbheara/names/normalize";
78
+ import namesPkg from "@finbheara/names/package.json";
79
+
80
+ import {
81
+ NAME_LEVEL_COLUMNS,
82
+ givenAbbreviates,
83
+ nameRelation
84
+ } from "@finbheara/names/normalize";
85
+ var NAME_LEVELS2 = [
86
+ ...NAME_LEVEL_COLUMNS,
87
+ "initial",
88
+ "middle",
89
+ "surname",
90
+ "like"
91
+ ];
92
+ var finer = (a, b) => NAME_LEVELS2.indexOf(a) <= NAME_LEVELS2.indexOf(b) ? a : b;
93
+ function nameLevel2(q, c) {
94
+ if (!q.isPerson() || !c.isPerson())
95
+ return null;
96
+ for (const level of NAME_LEVEL_COLUMNS)
97
+ if (q[level] === c[level])
98
+ return level;
99
+ if (q.surnameKey() !== c.surnameKey())
100
+ return null;
101
+ if (givenAbbreviates(q, c) || givenAbbreviates(c, q))
102
+ return "initial";
103
+ if (nameRelation(q, c).relation === "candidate")
104
+ return "middle";
105
+ return null;
106
+ }
107
+
108
+ var FEIS_LOCAL_ID_FLOOR2 = 17179869184;
109
+ var COLUMNS = `r.address AS address, r.competition AS competition, c.event AS event, c.year AS year,
110
+ c.tier AS tier, c.title AS title, c.age AS age, c.category AS category, c.gender AS gender,
111
+ c.feis_level AS feis_level, c.comp_no AS comp_no, r.place AS place,
112
+ r.place_printed AS place_printed, c.competitors AS competitors, r.name_printed AS name_printed,
113
+ r.dancer_id AS dancer_id, r.school_printed AS school_printed, r.school_id AS school_id,
114
+ r.yob AS yob, r.qualifier AS qualifier`;
115
+ function queryResults(rel, db, q) {
116
+ const where = [];
117
+ const args = [];
118
+ if (q.dancer_id !== undefined) {
119
+ where.push("r.dancer_id = ?");
120
+ args.push(q.dancer_id);
121
+ }
122
+ if (q.feis !== undefined) {
123
+ where.push("c.event = ? COLLATE NOCASE");
124
+ args.push(q.feis);
125
+ }
126
+ if (q.competition !== undefined) {
127
+ where.push("r.competition = ?");
128
+ args.push(q.competition);
129
+ }
130
+ if (q.school_id !== undefined) {
131
+ where.push("r.school_id = ?");
132
+ args.push(q.school_id);
133
+ }
134
+ if (where.length === 0)
135
+ throw new Error("archive-client: a results query names a dancer_id, a feis, a competition or a school_id");
136
+ if (q.year !== undefined) {
137
+ where.push("c.year = ?");
138
+ args.push(q.year);
139
+ }
140
+ const limit = q.limit ?? 500;
141
+ const all = rel.db(db).query(`SELECT ${COLUMNS} FROM results r JOIN competitions c ON c.file = r.file AND c.line = r.competition_line
142
+ WHERE ${where.join(" AND ")}
143
+ ORDER BY c.year, c.event, r.competition, r.place IS NULL, r.place, r.address`).all(...args);
144
+ return {
145
+ tag: rel.tag,
146
+ db,
147
+ asked: q,
148
+ rows: all.slice(0, limit).map((r) => ({
149
+ ...r,
150
+ dancer_scope: r.dancer_id === null ? null : r.dancer_id >= FEIS_LOCAL_ID_FLOOR2 ? "per-release" : "stable"
151
+ })),
152
+ truncated: Math.max(0, all.length - limit)
153
+ };
154
+ }
155
+
156
+ class ResultFinder2 {
157
+ rel;
158
+ constructor(rel) {
159
+ this.rel = rel;
160
+ }
161
+ async query(q) {
162
+ await this.rel.fetch("results");
163
+ return queryResults(this.rel, "results", q);
164
+ }
165
+ }
166
+
167
+ class MajorsFinder2 {
168
+ rel;
169
+ constructor(rel) {
170
+ this.rel = rel;
171
+ }
172
+ async db() {
173
+ await this.rel.fetch("majors");
174
+ return this.rel.db("majors");
175
+ }
176
+ async events() {
177
+ const db = await this.db();
178
+ const rows = new Map;
179
+ for (const r of db.query("SELECT c.event AS event, count(*) AS n FROM results r JOIN competitions c ON c.file = r.file AND c.line = r.competition_line GROUP BY c.event").all())
180
+ rows.set(r.event, r.n);
181
+ const events = db.query("SELECT event, json_group_array(DISTINCT year) AS years, count(*) AS n FROM competitions GROUP BY event ORDER BY event").all().map((e) => ({
182
+ event: e.event,
183
+ years: JSON.parse(e.years).filter((y) => y !== null).sort((a, b) => a - b),
184
+ competitions: e.n,
185
+ rows: rows.get(e.event) ?? 0
186
+ }));
187
+ return { tag: this.rel.tag, events };
188
+ }
189
+ async competitions(q) {
190
+ const db = await this.db();
191
+ const args = [q.event];
192
+ let year = "";
193
+ if (q.year !== undefined) {
194
+ year = " AND c.year = ?";
195
+ args.push(q.year);
196
+ }
197
+ const rows = new Map;
198
+ for (const r of db.query(`SELECT r.competition AS competition, count(*) AS n FROM results r JOIN competitions c ON c.file = r.file AND c.line = r.competition_line
199
+ WHERE c.event = ? COLLATE NOCASE${year} GROUP BY r.competition`).all(...args))
200
+ rows.set(r.competition, r.n);
201
+ const competitions = db.query(`SELECT c.competition AS competition, c.event AS event, c.year AS year, c.tier AS tier,
202
+ c.comp_no AS comp_no, c.title AS title, c.age AS age, c.gender AS gender,
203
+ c.category AS category, c.competitors AS competitors
204
+ FROM competitions c WHERE c.event = ? COLLATE NOCASE${year}
205
+ ORDER BY c.year, c.seq, c.competition`).all(...args).map((c) => ({ ...c, rows: rows.get(c.competition) ?? 0 }));
206
+ return { tag: this.rel.tag, competitions };
207
+ }
208
+ async results(q) {
209
+ await this.db();
210
+ return queryResults(this.rel, "majors", q);
211
+ }
212
+ }
213
+
214
+ function nameOrderOf(s) {
215
+ return s === "surname-first" || s === "given-first" || s === "unknown" ? s : undefined;
216
+ }
217
+ var PACKAGE_KEY_PARTS = [
218
+ `names=${namesPkg.version}`,
219
+ `domain=${domainPkg.version}`
220
+ ];
221
+ var ENCODER = personEncoder({ read: (p, o) => dancerName(p, o) });
222
+ var PERSON = { table: "person", value: "id", valueType: "INTEGER PRIMARY KEY" };
223
+ var ASKED = ["surname_key", "nn4", "blk_surname"];
224
+ var dancersIndex = {
225
+ name: "dancers",
226
+ version: "1",
227
+ inputs: ["results"],
228
+ keyParts: () => [...PACKAGE_KEY_PARTS],
229
+ says: "every published dancer print at the names package's keys, its dancers, schools and years",
230
+ build(rel, out) {
231
+ const schema = indexSchema(ENCODER, PERSON);
232
+ out.run(schema.table);
233
+ out.run("CREATE TABLE print_dancer(person INTEGER NOT NULL, dancer_id INTEGER, rows INTEGER NOT NULL, year_lo INTEGER, year_hi INTEGER)");
234
+ out.run("CREATE TABLE dancer(dancer_id INTEGER PRIMARY KEY, display TEXT NOT NULL, names_printed TEXT NOT NULL, genders TEXT NOT NULL, appearances INTEGER NOT NULL, kind TEXT, birth_lo INTEGER, birth_hi INTEGER, birth_source TEXT NOT NULL, birth_conflict INTEGER NOT NULL, rows INTEGER NOT NULL, year_lo INTEGER, year_hi INTEGER)");
235
+ out.run("CREATE TABLE dancer_school(dancer_id INTEGER NOT NULL, school_id TEXT, print TEXT NOT NULL, rows INTEGER NOT NULL, year_lo INTEGER, year_hi INTEGER)");
236
+ const src = rel.db("results");
237
+ const groups = src.query(`SELECT r.name_printed AS p, r.name_order_resolved AS o, r.dancer_id AS d,
238
+ r.school_printed AS sp, r.school_id AS s,
239
+ count(*) AS n, min(c.year) AS lo, max(c.year) AS hi
240
+ FROM results r JOIN competitions c ON c.file = r.file AND c.line = r.competition_line
241
+ GROUP BY 1, 2, 3, 4, 5 ORDER BY 1, 2, 3, 4, 5`).all();
242
+ const names = createIndex(out, ENCODER, PERSON);
243
+ const personOf = new Map;
244
+ let next = 0;
245
+ const idOf = (p, o) => {
246
+ const k = `${p}\x00${o}`;
247
+ let id = personOf.get(k);
248
+ if (id === undefined) {
249
+ const order = o === "given-first" || o === "surname-first" ? o : undefined;
250
+ const name = dancerName(p, order);
251
+ const reading = `${p}\x00${ENCODER.encode(name).reading}`;
252
+ id = personOf.get(reading);
253
+ if (id === undefined) {
254
+ id = ++next;
255
+ names.add(name, id);
256
+ personOf.set(reading, id);
257
+ }
258
+ personOf.set(k, id);
259
+ }
260
+ return id;
261
+ };
262
+ const span = (a, lo, hi) => {
263
+ if (lo !== null && (a.lo === null || lo < a.lo))
264
+ a.lo = lo;
265
+ if (hi !== null && (a.hi === null || hi > a.hi))
266
+ a.hi = hi;
267
+ };
268
+ const pd = new Map;
269
+ const ds = new Map;
270
+ const dt = new Map;
271
+ for (const g of groups) {
272
+ const person = idOf(g.p, g.o);
273
+ const k1 = `${person}\x00${g.d ?? ""}`;
274
+ const a = pd.get(k1) ?? { person, d: g.d, rows: 0, lo: null, hi: null };
275
+ a.rows += g.n;
276
+ span(a, g.lo, g.hi);
277
+ pd.set(k1, a);
278
+ if (g.d === null)
279
+ continue;
280
+ const k2 = `${g.d}\x00${g.s ?? ""}\x00${g.sp}`;
281
+ const b = ds.get(k2) ?? { d: g.d, s: g.s, sp: g.sp, rows: 0, lo: null, hi: null };
282
+ b.rows += g.n;
283
+ span(b, g.lo, g.hi);
284
+ ds.set(k2, b);
285
+ const t = dt.get(g.d) ?? { rows: 0, lo: null, hi: null };
286
+ t.rows += g.n;
287
+ span(t, g.lo, g.hi);
288
+ dt.set(g.d, t);
289
+ }
290
+ const putPd = out.prepare("INSERT INTO print_dancer VALUES (?, ?, ?, ?, ?)");
291
+ for (const a of pd.values())
292
+ putPd.run(a.person, a.d, a.rows, a.lo, a.hi);
293
+ const putDs = out.prepare("INSERT INTO dancer_school VALUES (?, ?, ?, ?, ?, ?)");
294
+ for (const b of ds.values())
295
+ putDs.run(b.d, b.s, b.sp, b.rows, b.lo, b.hi);
296
+ const putD = out.prepare("INSERT INTO dancer VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
297
+ for (const d of src.query("SELECT dancer_id, display, names_printed, genders, appearances, scope, birth_lo, birth_hi, birth_source, birth_conflict FROM dancers ORDER BY dancer_id").all()) {
298
+ const t = dt.get(d.dancer_id);
299
+ putD.run(d.dancer_id, d.display, d.names_printed, d.genders, d.appearances, d.scope, d.birth_lo, d.birth_hi, d.birth_source, d.birth_conflict, t?.rows ?? 0, t?.lo ?? null, t?.hi ?? null);
300
+ }
301
+ for (const s of schema.indexes)
302
+ if (ASKED.some((c) => s.endsWith(`(${c})`)))
303
+ out.run(s);
304
+ out.run("CREATE INDEX print_dancer_person ON print_dancer(person)");
305
+ out.run("CREATE INDEX print_dancer_dancer ON print_dancer(dancer_id)");
306
+ out.run("CREATE INDEX dancer_school_dancer ON dancer_school(dancer_id)");
307
+ }
308
+ };
309
+ function yearOf(asOf) {
310
+ if (asOf === undefined)
311
+ return null;
312
+ const m = /^(\d{4})/.exec(asOf.trim());
313
+ return m === null ? null : Number(m[1]);
314
+ }
315
+ var NAME_WEIGHT = {
316
+ nn0: 5,
317
+ nn1: 5,
318
+ nn2: 4.5,
319
+ nn3: 4,
320
+ nn4: 3,
321
+ initial: 2,
322
+ middle: 1.5,
323
+ surname: 1,
324
+ like: 1
325
+ };
326
+ var AGE_WEIGHT = {
327
+ fits: 2,
328
+ overlaps: 1,
329
+ unknown: 0.5,
330
+ unasked: 0,
331
+ contradicts: -10
332
+ };
333
+ var SCHOOL_RANK = {
334
+ same: 0,
335
+ moved: 1,
336
+ none: 2,
337
+ unresolved: 2,
338
+ unasked: 2
339
+ };
340
+ var SCHOOL_WEIGHT = {
341
+ same: 2,
342
+ moved: 1,
343
+ none: 0,
344
+ unresolved: 0,
345
+ unasked: 0
346
+ };
347
+
348
+ class DancerResolver2 {
349
+ rel;
350
+ printIds = new Map;
351
+ constructor(rel) {
352
+ this.rel = rel;
353
+ }
354
+ async resolve(q) {
355
+ const db = await this.rel.indexDb("dancers");
356
+ const asked = dancerName(q.name, q.order === "unknown" ? undefined : q.order);
357
+ if (!asked.isPerson())
358
+ return {
359
+ tag: this.rel.tag,
360
+ asked: q,
361
+ arm: "none",
362
+ candidates: [],
363
+ excluded: [],
364
+ unlinked: [],
365
+ refused: asked.printsNoName() ? "the print names no person" : "the print reads as no person (a bare word is a surname: search it)"
366
+ };
367
+ const names = createIndex(db, ENCODER, PERSON);
368
+ const hits = new Map;
369
+ for (const { value, row } of names.rows({ surname_key: [asked.surnameKey()], nn4: [asked.nn4] }, ["print", "reading"])) {
370
+ const c = ENCODER.decode(row);
371
+ const level = nameLevel2(asked, c);
372
+ if (level !== null)
373
+ hits.set(value, level);
374
+ }
375
+ return this.answer(db, q, hits);
376
+ }
377
+ async search(q) {
378
+ if (q.name !== undefined) {
379
+ const a = await this.resolve({ ...q, name: q.name });
380
+ return this.capped(a, q.limit);
381
+ }
382
+ const db = await this.rel.indexDb("dancers");
383
+ const hits = new Map;
384
+ const names = createIndex(db, ENCODER, PERSON);
385
+ if (q.surname !== undefined) {
386
+ const block = surnameBlock(foldNameKey(q.surname).toUpperCase());
387
+ if (block !== null)
388
+ for (const { value } of names.rows({ blk_surname: [block] }, ["print"]))
389
+ hits.set(value, "surname");
390
+ } else if (q.like !== undefined) {
391
+ const like = q.like.replace(/[\\%_]/g, (c) => `\\${c}`).replace(/\*/g, "%").replace(/\?/g, "_");
392
+ for (const r of db.query("SELECT id FROM person WHERE print LIKE ? ESCAPE '\\'").all(like))
393
+ hits.set(r.id, "like");
394
+ } else
395
+ throw new Error("archive-client: a dancer search names one of name, surname, like");
396
+ const a = await this.answer(db, { ...q, name: q.surname ?? q.like ?? "" }, hits, false);
397
+ return this.capped(a, q.limit);
398
+ }
399
+ capped(a, limit = 50) {
400
+ return { ...a, candidates: a.candidates.slice(0, limit) };
401
+ }
402
+ async get(dancerId) {
403
+ const db = await this.rel.indexDb("dancers");
404
+ const d = db.query("SELECT * FROM dancer WHERE dancer_id = ?").get(dancerId);
405
+ if (d === null)
406
+ return null;
407
+ const prints = db.query("SELECT p.print AS print, sum(x.rows) AS rows, min(x.year_lo) AS year_lo, max(x.year_hi) AS year_hi FROM print_dancer x JOIN person p ON p.id = x.person WHERE x.dancer_id = ? GROUP BY p.print ORDER BY rows DESC, print").all(dancerId);
408
+ return {
409
+ tag: this.rel.tag,
410
+ dancer_id: d.dancer_id,
411
+ display: d.display,
412
+ scope: d.dancer_id >= FEIS_LOCAL_ID_FLOOR2 ? "per-release" : "stable",
413
+ kind: d.kind,
414
+ names_printed: JSON.parse(d.names_printed),
415
+ genders: d.genders,
416
+ appearances: d.appearances,
417
+ rows: d.rows,
418
+ year_lo: d.year_lo,
419
+ year_hi: d.year_hi,
420
+ birth: birthOf(d),
421
+ prints,
422
+ schools: schoolsOf(db, dancerId)
423
+ };
424
+ }
425
+ async answer(db, q, hits, narrow = true) {
426
+ const asOf = yearOf(q.asOf);
427
+ const age = q.age === undefined ? undefined : this.readAge(q.age, asOf);
428
+ let schoolIds = null;
429
+ let school;
430
+ if (q.school !== undefined) {
431
+ const s = await this.rel.schools.resolve(q.school);
432
+ schoolIds = s.candidates.map((c) => c.school_id);
433
+ school = { arm: s.arm, ids: schoolIds };
434
+ }
435
+ const byDancer = new Map;
436
+ const unlinked = new Map;
437
+ const pq = db.query("SELECT x.dancer_id AS d, x.rows AS n, p.print AS print FROM print_dancer x JOIN person p ON p.id = x.person WHERE x.person = ?");
438
+ for (const [person, level] of hits)
439
+ for (const r of pq.all(person)) {
440
+ if (r.d === null) {
441
+ unlinked.set(r.print, (unlinked.get(r.print) ?? 0) + r.n);
442
+ continue;
443
+ }
444
+ const e = byDancer.get(r.d) ?? { level, prints: [] };
445
+ e.level = finer(e.level, level);
446
+ e.prints.push({ print: r.print, rows: r.n, level });
447
+ byDancer.set(r.d, e);
448
+ }
449
+ const dq = db.query("SELECT * FROM dancer WHERE dancer_id = ?");
450
+ const all = [];
451
+ for (const [id, e] of byDancer) {
452
+ const d = dq.get(id);
453
+ if (d === null)
454
+ continue;
455
+ const schools = schoolsOf(db, id);
456
+ const latest = latestSchool(schools, asOf);
457
+ const ageEv = ageEvidence(d, age?.age ?? null);
458
+ const schoolEv = schoolIds === null ? "unasked" : schoolIds.length === 0 ? "unresolved" : await this.schoolEvidence(schools, schoolIds, asOf);
459
+ all.push({
460
+ dancer_id: id,
461
+ display: d.display,
462
+ scope: id >= FEIS_LOCAL_ID_FLOOR2 ? "per-release" : "stable",
463
+ kind: d.kind,
464
+ score: NAME_WEIGHT[e.level] + AGE_WEIGHT[ageEv] + SCHOOL_WEIGHT[schoolEv],
465
+ evidence: { nameLevel: e.level, age: ageEv, school: schoolEv },
466
+ prints: e.prints.sort((a, b) => b.rows - a.rows || (a.print < b.print ? -1 : 1)),
467
+ rows: d.rows,
468
+ year_lo: d.year_lo,
469
+ year_hi: d.year_hi,
470
+ birth: birthOf(d),
471
+ school: latest
472
+ });
473
+ }
474
+ all.sort((a, b) => SCHOOL_RANK[a.evidence.school] - SCHOOL_RANK[b.evidence.school] || b.score - a.score || b.rows - a.rows || a.dancer_id - b.dancer_id);
475
+ const excluded = [];
476
+ let kept = all;
477
+ if (narrow)
478
+ kept = all.filter((c) => {
479
+ if (c.evidence.age !== "contradicts")
480
+ return true;
481
+ excluded.push({ candidate: c, why: "age" });
482
+ return false;
483
+ });
484
+ let preferred;
485
+ if (kept.length > 1 && school !== undefined)
486
+ for (const ev of ["same", "moved"]) {
487
+ const hit = kept.filter((c) => SCHOOL_RANK[c.evidence.school] === SCHOOL_RANK[ev]);
488
+ if (hit.length === 0)
489
+ continue;
490
+ if (hit.length === 1)
491
+ preferred = {
492
+ dancer_id: hit[0].dancer_id,
493
+ why: `the only candidate ${ev === "same" ? "whose latest school is" : "that ever danced for"} ${q.school}`
494
+ };
495
+ break;
496
+ }
497
+ return {
498
+ tag: this.rel.tag,
499
+ asked: q,
500
+ arm: kept.length === 0 ? "none" : kept.length === 1 ? "unique" : "ambiguous",
501
+ candidates: kept,
502
+ excluded,
503
+ ...preferred !== undefined ? { preferred } : {},
504
+ unlinked: [...unlinked].map(([print, rows]) => ({ print, rows })).sort((a, b) => b.rows - a.rows || (a.print < b.print ? -1 : 1)),
505
+ ...age !== undefined ? { age: age.answer } : {},
506
+ ...school !== undefined ? { school } : {}
507
+ };
508
+ }
509
+ async schoolEvidence(schools, ids, asOf) {
510
+ const rows = schools.filter((s) => asOf === null || s.year_lo === null || s.year_lo <= asOf);
511
+ const meets = async (s) => {
512
+ if (s.school_id !== null)
513
+ return ids.includes(s.school_id);
514
+ let read = this.printIds.get(s.print);
515
+ if (read === undefined) {
516
+ read = (await this.rel.schools.resolve(s.print)).candidates.map((c) => c.school_id);
517
+ this.printIds.set(s.print, read);
518
+ }
519
+ return read.some((id) => ids.includes(id));
520
+ };
521
+ const top = (s) => asOf !== null && s.year_hi !== null ? Math.min(s.year_hi, asOf) : s.year_hi ?? -1;
522
+ const latest = Math.max(-1, ...rows.map(top));
523
+ let earlier = false;
524
+ for (const s of rows) {
525
+ if (!await meets(s))
526
+ continue;
527
+ if (top(s) === latest)
528
+ return "same";
529
+ earlier = true;
530
+ }
531
+ return earlier ? "moved" : "none";
532
+ }
533
+ readAge(a, asOf) {
534
+ const done = (age, note) => {
535
+ const read = age?.isAge() ? age : null;
536
+ const w = read === null ? null : read.birthYears();
537
+ return {
538
+ age: read,
539
+ answer: {
540
+ read: read === null ? null : read.ag1,
541
+ birth: w === null ? null : { from: w.from, to: w.to },
542
+ ...note !== undefined ? { note } : {}
543
+ }
544
+ };
545
+ };
546
+ if ("yob" in a)
547
+ return done(Age.fromYOB(a.yob));
548
+ if ("band" in a) {
549
+ const year = a.year ?? asOf ?? new Date().getFullYear();
550
+ return done(Age.from(a.band, { year }));
551
+ }
552
+ const c = this.rel.db("results").query("SELECT age, year FROM competitions WHERE competition = ? LIMIT 1").get(a.comp);
553
+ if (c === null)
554
+ return done(null, `the release holds no competition ${a.comp} (a feis's own code is \`comps.resolve\`'s)`);
555
+ if (c.age === null || c.year === null)
556
+ return done(null, `competition ${a.comp} prints no age or year`);
557
+ return done(Age.from(c.age, { year: c.year }));
558
+ }
559
+ }
560
+ function birthOf(d) {
561
+ return {
562
+ lo: d.birth_lo,
563
+ hi: d.birth_hi,
564
+ source: d.birth_source,
565
+ conflict: d.birth_conflict !== 0
566
+ };
567
+ }
568
+ function schoolsOf(db, dancerId) {
569
+ return db.query("SELECT school_id, print, rows, year_lo, year_hi FROM dancer_school WHERE dancer_id = ? ORDER BY year_hi DESC, rows DESC, print").all(dancerId);
570
+ }
571
+ function latestSchool(schools, asOf) {
572
+ let best = null;
573
+ for (const s of schools) {
574
+ if (asOf !== null && s.year_lo !== null && s.year_lo > asOf)
575
+ continue;
576
+ const hi = asOf !== null && s.year_hi !== null ? Math.min(s.year_hi, asOf) : s.year_hi;
577
+ const bh = best === null ? null : asOf !== null && best.year_hi !== null ? Math.min(best.year_hi, asOf) : best.year_hi;
578
+ if (best === null || (hi ?? -1) > (bh ?? -1) || (hi ?? -1) === (bh ?? -1) && s.rows > best.rows)
579
+ best = s;
580
+ }
581
+ return best;
582
+ }
583
+ function ageEvidence(d, asked) {
584
+ if (asked === null)
585
+ return "unasked";
586
+ if (d.birth_lo === null && d.birth_hi === null)
587
+ return "unknown";
588
+ const own = Age.fromYOB(d.birth_lo, d.birth_hi);
589
+ if (!own.isAge())
590
+ return "unknown";
591
+ if (own.fitsIn(asked))
592
+ return "fits";
593
+ if (own.overlaps(asked))
594
+ return "overlaps";
595
+ return "contradicts";
596
+ }
597
+
598
+ import { Age as Age2 } from "@finbheara/domain/age";
599
+ import {
600
+ compTitle,
601
+ matchTitle,
602
+ populationIn,
603
+ titleRelation
604
+ } from "@finbheara/domain/title";
605
+ var LEVEL = Object.freeze({
606
+ 100: "FF",
607
+ 200: "BG",
608
+ 300: "AB",
609
+ 400: "NV",
610
+ 500: "PW",
611
+ 600: "PC",
612
+ 700: "OC"
613
+ });
614
+ var SCHEMA = `
615
+ CREATE TABLE comp(
616
+ competition TEXT PRIMARY KEY, feis_id TEXT NOT NULL, brand_id TEXT, year INTEGER,
617
+ code TEXT NOT NULL, code_fold TEXT NOT NULL, title TEXT, age TEXT, gender TEXT,
618
+ feis_level INTEGER, round TEXT, platform TEXT NOT NULL, captures INTEGER NOT NULL,
619
+ file TEXT NOT NULL, line INTEGER NOT NULL
620
+ );
621
+ `;
622
+ var INDEXES = [
623
+ "CREATE INDEX comp_feis_code ON comp(feis_id, code_fold)",
624
+ "CREATE INDEX comp_brand_year ON comp(brand_id, year)"
625
+ ];
626
+ var fold = (code) => code.trim().toLowerCase();
627
+ var compsIndex = {
628
+ name: "comps",
629
+ version: "1",
630
+ inputs: ["results"],
631
+ says: "every competition by its feis and its own code, with its series and year",
632
+ build(rel, out) {
633
+ out.run(SCHEMA);
634
+ const src = rel.db("results");
635
+ const brand = new Map;
636
+ for (const f of src.query("SELECT feis_id, feis_brand_id, year FROM feises ORDER BY file, line").all())
637
+ if (!brand.has(f.feis_id))
638
+ brand.set(f.feis_id, { brand: f.feis_brand_id, year: f.year });
639
+ const captures = new Map;
640
+ for (const r of src.query("SELECT competition, count(*) AS n FROM competitions GROUP BY competition").all())
641
+ captures.set(r.competition, r.n);
642
+ const put = out.prepare("INSERT OR IGNORE INTO comp VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
643
+ for (const c of src.query("SELECT competition, event, year, comp_no, title, age, gender, feis_level, round, source, file, line FROM competitions WHERE comp_no IS NOT NULL AND trim(comp_no) != '' ORDER BY file, line").iterate()) {
644
+ const f = brand.get(c.event);
645
+ put.run(c.competition, c.event, f?.brand ?? null, c.year ?? f?.year ?? null, c.comp_no.trim(), fold(c.comp_no), c.title, c.age, c.gender, c.feis_level, c.round, c.source, captures.get(c.competition) ?? 1, c.file, c.line);
646
+ }
647
+ for (const s of INDEXES)
648
+ out.run(s);
649
+ }
650
+ };
651
+ function compOf(r) {
652
+ const lv = r.feis_level;
653
+ const kind = lv === null ? "unrated" : lv >= 600 ? "championship" : "grade";
654
+ const pop = populationIn(r.gender ?? "") || populationIn(r.title ?? "");
655
+ const round = r.round ?? (r.title === null ? null : compTitle(r.title).round?.toString() ?? null);
656
+ return {
657
+ competition: r.competition,
658
+ feis_id: r.feis_id,
659
+ year: r.year,
660
+ code: r.code,
661
+ title: r.title,
662
+ kind,
663
+ feis_level: lv,
664
+ level: lv === null ? null : LEVEL[lv] ?? null,
665
+ age: r.age,
666
+ population: pop === "" ? null : pop,
667
+ round,
668
+ canonical: canonicalOf2(lv, r.age),
669
+ platform: r.platform,
670
+ captures: r.captures
671
+ };
672
+ }
673
+ function canonicalOf2(level, age) {
674
+ if (level !== 600 && level !== 700 || age === null)
675
+ return null;
676
+ const a = Age2.from(age, { year: null });
677
+ const p = a.parts[0];
678
+ if (!a.isValid() || p === undefined)
679
+ return null;
680
+ const n = a.kind === "under" ? p : a.kind === "over" ? p + 1 : null;
681
+ if (n === null || n > 99)
682
+ return null;
683
+ return `${level / 100}${String(n).padStart(2, "0")}${level === 600 ? "PC" : "OC"}`;
684
+ }
685
+ var entryOf = (c) => ({
686
+ title: c.title ?? "",
687
+ ...c.age !== null ? { age: c.age } : {},
688
+ comp: c
689
+ });
690
+
691
+ class CompResolver2 {
692
+ rel;
693
+ constructor(rel) {
694
+ this.rel = rel;
695
+ }
696
+ async place(feis, year) {
697
+ const asked = feis.trim();
698
+ const none = {
699
+ asked,
700
+ feis_ids: [],
701
+ brand_id: null,
702
+ year: year ?? null,
703
+ held: false,
704
+ via: null
705
+ };
706
+ const found = await this.rel.feises.find({ id: asked });
707
+ const exact = found.feises.find((f) => f.feis_id === (found.redirected?.to ?? asked));
708
+ if (exact !== undefined) {
709
+ const y = year ?? exact.year;
710
+ if (y === exact.year || exact.brand_id === null)
711
+ return {
712
+ asked,
713
+ feis_ids: [exact.feis_id],
714
+ brand_id: exact.brand_id,
715
+ year: y,
716
+ held: y === exact.year,
717
+ via: "id"
718
+ };
719
+ return this.ofSeries(asked, exact.brand_id, y, "id");
720
+ }
721
+ if (found.feises.length > 0) {
722
+ const brands = [...new Set(found.feises.map((f) => f.brand_id))];
723
+ const instanceYears = [
724
+ ...new Set(found.feises.filter((f) => f.instance_id === (found.redirected?.to ?? asked)).map((f) => f.year))
725
+ ];
726
+ const y = year ?? (instanceYears.length === 1 ? instanceYears[0] ?? null : null);
727
+ const only = brands[0];
728
+ if (brands.length === 1 && only !== null && only !== undefined)
729
+ return this.ofSeries(asked, only, y, "series");
730
+ }
731
+ const m = /^([^:]+):(.*?)(\d{2})$/.exec(asked);
732
+ if (m !== null) {
733
+ const [, token, stem, yy] = m;
734
+ const brands = await this.rel.feises.codeStem(token, stem);
735
+ const y = year ?? 2000 + Number(yy);
736
+ if (brands.length === 1)
737
+ return this.ofSeries(asked, brands[0], y, "code-stem");
738
+ if (brands.length > 1)
739
+ return { ...none, year: y, via: "code-stem", series: brands };
740
+ return { ...none, year: y };
741
+ }
742
+ const s = await this.rel.feises.series(asked);
743
+ if (s.arm === "unique" && s.series_id !== null)
744
+ return this.ofSeries(asked, s.series_id, year ?? null, s.via === "name" ? "name" : "code-stem");
745
+ if (s.arm === "ambiguous")
746
+ return {
747
+ ...none,
748
+ via: s.via === "name" ? "name" : "code-stem",
749
+ series: s.candidates.map((c) => c.series_id)
750
+ };
751
+ return none;
752
+ }
753
+ async ofSeries(asked, brand, year, via) {
754
+ const db = await this.rel.indexDb("feises");
755
+ if (year === null)
756
+ year = db.query("SELECT max(year) AS y FROM feis WHERE brand_id = ?").get(brand)?.y ?? null;
757
+ const ids = year === null ? [] : db.query("SELECT feis_id FROM feis WHERE brand_id = ? AND year = ? ORDER BY feis_id").all(brand, year).map((r) => r.feis_id);
758
+ return { asked, feis_ids: ids, brand_id: brand, year, held: ids.length > 0, via };
759
+ }
760
+ async years(brand) {
761
+ if (brand === null)
762
+ return [];
763
+ const db = await this.rel.indexDb("comps");
764
+ return db.query("SELECT DISTINCT year FROM comp WHERE brand_id = ? AND year IS NOT NULL ORDER BY year").all(brand).map((r) => r.year);
765
+ }
766
+ async compsOf(feisIds, code) {
767
+ if (feisIds.length === 0)
768
+ return [];
769
+ const db = await this.rel.indexDb("comps");
770
+ const marks = feisIds.map(() => "?").join(", ");
771
+ const sql = `SELECT * FROM comp WHERE feis_id IN (${marks})${code !== undefined ? " AND code_fold = ?" : ""} ORDER BY file, line`;
772
+ const args = code !== undefined ? [...feisIds, fold(code)] : [...feisIds];
773
+ return db.query(sql).all(...args).map(compOf);
774
+ }
775
+ async idsOf(brand, year) {
776
+ return (await this.ofSeries("", brand, year, "series")).feis_ids;
777
+ }
778
+ async syllabus(feis, year) {
779
+ const place = await this.place(feis, year);
780
+ return {
781
+ tag: this.rel.tag,
782
+ feis: place,
783
+ year: place.year,
784
+ held: place.held,
785
+ entries: await this.compsOf(place.feis_ids),
786
+ years: await this.years(place.brand_id)
787
+ };
788
+ }
789
+ async syllabusFor(series, year) {
790
+ const asked = typeof series === "string" ? series.trim() : series.series_id ?? "";
791
+ const place = await this.place(asked, year);
792
+ const years = await this.years(place.brand_id);
793
+ const answer = (arm, read, why) => {
794
+ const entries = read?.entries ?? [];
795
+ const championships = entries.filter((c) => c.kind === "championship").length;
796
+ return {
797
+ tag: this.rel.tag,
798
+ asked: { series: asked, year: year ?? place.year },
799
+ arm,
800
+ series_id: place.brand_id,
801
+ feis: place,
802
+ year: read?.year ?? null,
803
+ held: place.held,
804
+ source: read === null ? null : "results",
805
+ feis_ids: read?.feis_ids ?? [],
806
+ entries,
807
+ years,
808
+ championships,
809
+ ...why !== undefined ? { why } : {},
810
+ ...read !== null && entries.length > 0 && championships === 0 ? {
811
+ note: "no championship is in the results this syllabus is read from: a feis whose platform did not tabulate its championships lists none here"
812
+ } : {}
813
+ };
814
+ };
815
+ if (place.series !== undefined)
816
+ return answer("ambiguous", null, `"${asked}" names ${place.series.length} series: ${place.series.join(", ")}`);
817
+ if (place.feis_ids.length === 0 && place.brand_id === null)
818
+ return answer("none", null, `the release holds no feis or series "${asked}"`);
819
+ if (place.held)
820
+ return answer("unique", {
821
+ year: place.year,
822
+ feis_ids: place.feis_ids,
823
+ entries: await this.compsOf(place.feis_ids)
824
+ });
825
+ const newest = years[years.length - 1];
826
+ if (newest === undefined || place.brand_id === null)
827
+ return answer("unique", null, `the release holds no comp of ${asked}'s series`);
828
+ const ids = await this.idsOf(place.brand_id, newest);
829
+ return answer("unique", { year: newest, feis_ids: ids, entries: await this.compsOf(ids) }, `the release holds no ${place.year ?? "asked"} feis-year of the series: read from ${newest}, its newest`);
830
+ }
831
+ async resolve(q) {
832
+ const place = await this.place(q.feis, q.year);
833
+ const base = {
834
+ tag: this.rel.tag,
835
+ asked: q,
836
+ feis: place
837
+ };
838
+ const answer = (arm, source, candidates, more = {}) => {
839
+ const u = arm === "unique" ? candidates[0] ?? null : null;
840
+ return {
841
+ ...base,
842
+ arm,
843
+ source,
844
+ kind: u?.kind ?? null,
845
+ level: u?.level ?? null,
846
+ feis_level: u?.feis_level ?? null,
847
+ age: u?.age ?? null,
848
+ population: u?.population ?? null,
849
+ round: u?.round ?? null,
850
+ canonical: u?.canonical ?? null,
851
+ title: u?.title ?? null,
852
+ competition: u?.competition ?? null,
853
+ candidates,
854
+ syllabus_source: source === null ? null : "results",
855
+ ...more
856
+ };
857
+ };
858
+ if (place.series !== undefined)
859
+ return answer("ambiguous", null, [], {
860
+ why: `the feis's code stem names ${place.series.length} series: ${place.series.join(", ")}`
861
+ });
862
+ if (place.feis_ids.length === 0 && place.brand_id === null)
863
+ return answer("none", null, [], { why: `the release holds no feis "${place.asked}"` });
864
+ const byTitle = async (ids, from, why) => {
865
+ if (q.title === undefined || q.title.trim() === "")
866
+ return answer("none", null, [], { why, ...from !== undefined ? { from } : {} });
867
+ const m = matchTitle(q.title, (await this.compsOf(ids)).map(entryOf));
868
+ const hits = m.entries.map((e) => e.comp);
869
+ const arm = hits.length === 0 ? "none" : hits.length === 1 ? "unique" : "ambiguous";
870
+ return answer(arm, hits.length === 0 ? null : "title", hits, {
871
+ ...from !== undefined ? { from } : {},
872
+ ...arm !== "unique" ? {
873
+ why: arm === "none" ? `${why}, and the title names no comp of that syllabus` : `${why}, and the title names ${hits.length} comps of that syllabus`
874
+ } : { why }
875
+ });
876
+ };
877
+ if (place.held) {
878
+ const hits = await this.compsOf(place.feis_ids, q.code);
879
+ if (hits.length === 1)
880
+ return answer("unique", "syllabus", hits);
881
+ if (hits.length > 1)
882
+ return answer("ambiguous", "syllabus", hits, {
883
+ why: `${hits.length} comps print code ${q.code}`
884
+ });
885
+ return byTitle(place.feis_ids, undefined, `no comp of ${place.feis_ids.join(", ")} prints code ${q.code}`);
886
+ }
887
+ const earlier = (await this.years(place.brand_id)).filter((y) => place.year === null || y < place.year).reverse();
888
+ if (earlier.length === 0 || place.brand_id === null)
889
+ return answer("none", null, [], {
890
+ why: `the release holds no year of ${place.asked}'s series before ${place.year ?? "?"}`
891
+ });
892
+ for (const y of earlier) {
893
+ const ids = await this.idsOf(place.brand_id, y);
894
+ const hits = await this.compsOf(ids, q.code);
895
+ if (hits.length === 0)
896
+ continue;
897
+ const from = { feis_ids: ids, year: y };
898
+ if (hits.length > 1)
899
+ return answer("ambiguous", "prior-year", hits, {
900
+ from,
901
+ why: `${hits.length} comps of ${y} print code ${q.code}`
902
+ });
903
+ const hit = hits[0];
904
+ if (q.title === undefined || q.title.trim() === "")
905
+ return answer("unique", "prior-year", hits, { from, check: "unchecked" });
906
+ const m = matchTitle(q.title, [entryOf(hit)]);
907
+ const r = titleRelation(m.title, compTitle(hit.title ?? "", {}));
908
+ const check = {
909
+ title: q.title,
910
+ agrees: m.isSingular(),
911
+ age: r.age.relation,
912
+ population: r.population
913
+ };
914
+ if (check.agrees)
915
+ return answer("unique", "prior-year", hits, { from, check });
916
+ const t = await byTitle(ids, from, `code ${q.code} of ${y} is "${hit.title}", which the title does not name`);
917
+ return { ...t, check };
918
+ }
919
+ const newest = earlier[0];
920
+ const ids = await this.idsOf(place.brand_id, newest);
921
+ const champs = (await this.compsOf(ids)).some((c) => c.kind === "championship");
922
+ return byTitle(ids, { feis_ids: ids, year: newest }, `no year of the series before ${place.year ?? "?"} prints code ${q.code}${champs ? "" : `, and the ${newest} syllabus, read from results, lists no championship`}`);
923
+ }
924
+ }
925
+
926
+ import { readFileSync } from "fs";
927
+ var SCHEMA2 = `
928
+ CREATE TABLE feis(
929
+ feis_id TEXT PRIMARY KEY, source TEXT NOT NULL, token TEXT NOT NULL, host_id TEXT NOT NULL,
930
+ name TEXT NOT NULL, date_lo TEXT, date_hi TEXT, date_printed TEXT, year INTEGER,
931
+ location TEXT, instance_id TEXT, brand_id TEXT, regions TEXT NOT NULL,
932
+ competitions INTEGER NOT NULL, rows INTEGER NOT NULL, captures INTEGER NOT NULL
933
+ );
934
+ CREATE TABLE redirect(old_id TEXT PRIMARY KEY, new_id TEXT NOT NULL, kind TEXT NOT NULL);
935
+ `;
936
+ var INDEXES2 = [
937
+ "CREATE INDEX feis_host ON feis(host_id COLLATE NOCASE)",
938
+ "CREATE INDEX feis_token ON feis(token)",
939
+ "CREATE INDEX feis_source ON feis(source)",
940
+ "CREATE INDEX feis_instance ON feis(instance_id)",
941
+ "CREATE INDEX feis_brand ON feis(brand_id)",
942
+ "CREATE INDEX feis_year ON feis(year)"
943
+ ];
944
+ function readJsonl(path) {
945
+ return readFileSync(path, "utf8").split(`
946
+ `).filter((l) => l.trim() !== "").map((l) => JSON.parse(l));
947
+ }
948
+ var feisesIndex = {
949
+ name: "feises",
950
+ version: "2",
951
+ inputs: ["results", "feis-redirects.jsonl"],
952
+ says: "every feis by id, platform, code, series and name, with its counts and regions",
953
+ build(rel, out) {
954
+ out.run(SCHEMA2);
955
+ const src = rel.db("results");
956
+ const counts = new Map;
957
+ for (const r of src.query("SELECT event, count(*) AS n, json_group_array(DISTINCT feis_region) AS regions FROM competitions GROUP BY event").all())
958
+ counts.set(r.event, {
959
+ comps: r.n,
960
+ regions: new Set(JSON.parse(r.regions).filter((x) => x !== null))
961
+ });
962
+ const rows = new Map;
963
+ for (const r of src.query("SELECT c.event AS event, sum(r.n) AS n FROM (SELECT competition, count(*) AS n FROM results GROUP BY competition) r JOIN (SELECT DISTINCT competition, event FROM competitions) c ON c.competition = r.competition GROUP BY c.event").all())
964
+ rows.set(r.event, r.n);
965
+ const put = out.prepare("INSERT OR IGNORE INTO feis VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
966
+ const captures = new Map;
967
+ const feisRows = src.query("SELECT feis_id, source, host_id, name_printed, date_lo, date_hi, date_printed, year, location_printed, feis_instance_id, feis_brand_id FROM feises ORDER BY file, line").all();
968
+ for (const f of feisRows)
969
+ captures.set(f.feis_id, (captures.get(f.feis_id) ?? 0) + 1);
970
+ for (const f of feisRows) {
971
+ const c = counts.get(f.feis_id);
972
+ const colon = f.feis_id.indexOf(":");
973
+ put.run(f.feis_id, f.source, colon === -1 ? f.source : f.feis_id.slice(0, colon), f.host_id, f.name_printed, f.date_lo, f.date_hi, f.date_printed, f.year, f.location_printed, f.feis_instance_id, f.feis_brand_id, JSON.stringify([...c?.regions ?? []].sort()), c?.comps ?? 0, rows.get(f.feis_id) ?? 0, captures.get(f.feis_id) ?? 0);
974
+ }
975
+ const red = out.prepare("INSERT OR REPLACE INTO redirect VALUES (?, ?, ?)");
976
+ for (const r of readJsonl(rel.path("feis-redirects.jsonl")))
977
+ red.run(r.old_id, r.new_id, r.kind);
978
+ for (const s of INDEXES2)
979
+ out.run(s);
980
+ }
981
+ };
982
+ var NO_CALENDAR2 = "the release holds a feis-year once its results are captured and carries no calendar of scheduled feiseanna";
983
+ var globToLike = (g) => g.replace(/[\\%_]/g, (c) => `\\${c}`).replace(/\*/g, "%").replace(/\?/g, "_");
984
+
985
+ class FeisFinder2 {
986
+ rel;
987
+ constructor(rel) {
988
+ this.rel = rel;
989
+ }
990
+ async find(q) {
991
+ const db = await this.rel.indexDb("feises");
992
+ const where = [];
993
+ const args = [];
994
+ let redirected;
995
+ if (q.platform !== undefined) {
996
+ where.push("(source = ? OR token = ?)");
997
+ args.push(q.platform, q.platform);
998
+ }
999
+ if (q.code !== undefined) {
1000
+ where.push("host_id = ? COLLATE NOCASE");
1001
+ args.push(q.code);
1002
+ }
1003
+ if (q.id !== undefined) {
1004
+ let id = q.id;
1005
+ const seen = new Set;
1006
+ for (;; ) {
1007
+ const r = db.query("SELECT new_id FROM redirect WHERE old_id = ?").get(id);
1008
+ if (r === null || seen.has(r.new_id))
1009
+ break;
1010
+ seen.add(id);
1011
+ id = r.new_id;
1012
+ }
1013
+ if (id !== q.id)
1014
+ redirected = { from: q.id, to: id };
1015
+ where.push("(feis_id = ? OR instance_id = ? OR brand_id = ?)");
1016
+ args.push(id, id, id);
1017
+ }
1018
+ if (q.name !== undefined) {
1019
+ where.push("name LIKE ? ESCAPE '\\'");
1020
+ args.push(globToLike(q.name));
1021
+ }
1022
+ if (q.year !== undefined) {
1023
+ where.push("year = ?");
1024
+ args.push(q.year);
1025
+ }
1026
+ const sql = `SELECT * FROM feis${where.length > 0 ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY date_lo, feis_id`;
1027
+ const feises = db.query(sql).all(...args).map((r) => ({
1028
+ ...r,
1029
+ regions: JSON.parse(r.regions)
1030
+ }));
1031
+ return { tag: this.rel.tag, feises, ...redirected !== undefined ? { redirected } : {} };
1032
+ }
1033
+ async codeStem(token, stem) {
1034
+ const db = await this.rel.indexDb("feises");
1035
+ const like = db.query("SELECT host_id, year, brand_id FROM feis WHERE token = ? AND host_id LIKE ? ESCAPE '\\' COLLATE NOCASE").all(token, `${stem.replace(/[\\%_]/g, (c) => `\\${c}`)}__`);
1036
+ const fits = like.filter((f) => {
1037
+ const d = /(\d{2})$/.exec(f.host_id);
1038
+ return d !== null && f.host_id.slice(0, -2).toLowerCase() === stem.toLowerCase() && f.year !== null && f.year % 100 === Number(d[1]) && f.brand_id !== null;
1039
+ });
1040
+ return [...new Set(fits.map((f) => f.brand_id))].sort();
1041
+ }
1042
+ async seriesOf(brand) {
1043
+ const db = await this.rel.indexDb("feises");
1044
+ const rows = db.query("SELECT * FROM feis WHERE brand_id = ? ORDER BY year, date_lo, feis_id").all(brand);
1045
+ const byYear = new Map;
1046
+ for (const r of rows) {
1047
+ const i = byYear.get(r.year);
1048
+ if (i === undefined)
1049
+ byYear.set(r.year, {
1050
+ year: r.year,
1051
+ feis_ids: [r.feis_id],
1052
+ date_lo: r.date_lo,
1053
+ name: r.name
1054
+ });
1055
+ else
1056
+ i.feis_ids.push(r.feis_id);
1057
+ }
1058
+ const instances = [...byYear.values()];
1059
+ const newest = rows[rows.length - 1];
1060
+ return {
1061
+ series_id: brand,
1062
+ name: newest?.name ?? "",
1063
+ host: newest?.source ?? "",
1064
+ hosts: [...new Set(rows.map((r) => r.source))].sort(),
1065
+ codes: [...new Set(rows.map((r) => r.host_id))].sort(),
1066
+ years: instances.map((i) => i.year).filter((y) => y !== null),
1067
+ instances
1068
+ };
1069
+ }
1070
+ async series(q) {
1071
+ const asked = q.trim();
1072
+ const answer = async (brands, via, more = {}) => {
1073
+ const candidates = [];
1074
+ for (const b of brands)
1075
+ candidates.push(await this.seriesOf(b));
1076
+ const arm = candidates.length === 0 ? "none" : candidates.length === 1 ? "unique" : "ambiguous";
1077
+ const u = arm === "unique" ? candidates[0] : null;
1078
+ return {
1079
+ tag: this.rel.tag,
1080
+ asked,
1081
+ arm,
1082
+ via: candidates.length === 0 ? null : via,
1083
+ series_id: u?.series_id ?? null,
1084
+ name: u?.name ?? null,
1085
+ host: u?.host ?? null,
1086
+ years: u?.years ?? [],
1087
+ instances: u?.instances ?? [],
1088
+ next: null,
1089
+ ...u !== null ? { next_why: NO_CALENDAR2 } : {},
1090
+ candidates,
1091
+ ...arm === "ambiguous" ? { why: `${candidates.length} series answer "${asked}": ${brands.join(", ")}` } : {},
1092
+ ...more
1093
+ };
1094
+ };
1095
+ if (asked === "")
1096
+ return answer([], null, { why: "no series was asked" });
1097
+ const byId = await this.find({ id: asked });
1098
+ if (byId.feises.length > 0) {
1099
+ const brands = [...new Set(byId.feises.map((f) => f.brand_id))];
1100
+ const filed = brands.filter((b) => b !== null).sort();
1101
+ const redirected = byId.redirected !== undefined ? { redirected: byId.redirected } : {};
1102
+ if (filed.length === 0)
1103
+ return answer([], null, {
1104
+ ...redirected,
1105
+ why: `the release files ${byId.feises.map((f) => f.feis_id).join(", ")} under no series`
1106
+ });
1107
+ return answer(filed, "id", redirected);
1108
+ }
1109
+ const code = /^([^:\s]+):(.*?)(\d{2})?$/.exec(asked);
1110
+ if (code !== null) {
1111
+ const [, token, stem] = code;
1112
+ const brands = stem === "" ? [] : await this.codeStem(token, stem);
1113
+ if (brands.length > 0)
1114
+ return answer(brands, "code-stem");
1115
+ }
1116
+ const glob = /[*?]/.test(asked) ? asked : `*${asked}*`;
1117
+ const byName = await this.find({ name: glob });
1118
+ const brands = [
1119
+ ...new Set(byName.feises.map((f) => f.brand_id).filter((b) => b !== null))
1120
+ ].sort();
1121
+ if (brands.length > 0)
1122
+ return answer(brands, "name");
1123
+ return answer([], null, {
1124
+ why: byName.feises.length > 0 ? `the feises named "${asked}" are filed under no series` : `the release holds no feis or series "${asked}"`
1125
+ });
1126
+ }
1127
+ }
1128
+
1129
+ import { Database } from "bun:sqlite";
1130
+ import { createHash as createHash2 } from "crypto";
1131
+ import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync } from "fs";
1132
+ import { join } from "path";
1133
+
1134
+ import {
1135
+ createIndex as createIndex2,
1136
+ indexSchema as indexSchema2,
1137
+ roleEncoder
1138
+ } from "@finbheara/domain/noun-index";
1139
+ import { roleName } from "@finbheara/domain/person";
1140
+ var ENCODER2 = roleEncoder();
1141
+ var WHO = { table: "who", value: "entry", valueType: "INTEGER PRIMARY KEY" };
1142
+ var ASKED2 = ["surname_key", "nn4", "id"];
1143
+ var SCHEMA3 = `
1144
+ CREATE TABLE entry(entry INTEGER PRIMARY KEY, source TEXT NOT NULL, clrg INTEGER, judge_id INTEGER, print TEXT NOT NULL);
1145
+ CREATE TABLE staff(school_id TEXT NOT NULL, name TEXT NOT NULL, clrg INTEGER, role TEXT, credentials TEXT NOT NULL,
1146
+ years TEXT NOT NULL, y_from INTEGER, y_to INTEGER, former INTEGER NOT NULL, provisional INTEGER NOT NULL, entry INTEGER NOT NULL);
1147
+ CREATE TABLE td(school_id TEXT NOT NULL, clrg INTEGER NOT NULL, via TEXT NOT NULL, captured TEXT NOT NULL);
1148
+ CREATE TABLE register(clrg INTEGER NOT NULL, year INTEGER NOT NULL, cat TEXT NOT NULL, name_printed TEXT NOT NULL,
1149
+ region TEXT NOT NULL, region_code TEXT, country TEXT NOT NULL);
1150
+ CREATE TABLE sat(clrg INTEGER, judge_id INTEGER, kind TEXT NOT NULL, event TEXT, year INTEGER, seats INTEGER NOT NULL, comps INTEGER NOT NULL);
1151
+ CREATE TABLE school(school_id TEXT PRIMARY KEY, display TEXT NOT NULL);
1152
+ `;
1153
+ var INDEXES3 = [
1154
+ "CREATE INDEX entry_clrg ON entry(clrg)",
1155
+ "CREATE INDEX entry_judge ON entry(judge_id)",
1156
+ "CREATE INDEX staff_clrg ON staff(clrg)",
1157
+ "CREATE INDEX staff_entry ON staff(entry)",
1158
+ "CREATE INDEX td_clrg ON td(clrg)",
1159
+ "CREATE INDEX register_clrg ON register(clrg)",
1160
+ "CREATE INDEX sat_clrg ON sat(clrg)",
1161
+ "CREATE INDEX sat_judge ON sat(judge_id)"
1162
+ ];
1163
+ var staffIndex = {
1164
+ name: "staff",
1165
+ version: "1",
1166
+ inputs: ["results", "school-staff.jsonl", "school-teachers.jsonl", "school-registry.jsonl"],
1167
+ keyParts: () => [...PACKAGE_KEY_PARTS],
1168
+ says: "teachers and adjudicators by name and TD number: the schools they taught at, the register, the seats they sat",
1169
+ build(rel, out) {
1170
+ const schema = indexSchema2(ENCODER2, WHO);
1171
+ out.run(schema.table);
1172
+ out.run(SCHEMA3);
1173
+ const who = createIndex2(out, ENCODER2, WHO);
1174
+ const putEntry = out.prepare("INSERT INTO entry VALUES (?, ?, ?, ?, ?)");
1175
+ let next = 0;
1176
+ const add = (role, print, clrg, judgeId, schools, source) => {
1177
+ const id = ++next;
1178
+ who.add({ name: roleName(role, print), id: clrg === null ? null : String(clrg), role, schools }, id);
1179
+ putEntry.run(id, source, clrg, judgeId, print);
1180
+ return id;
1181
+ };
1182
+ const putStaff = out.prepare("INSERT INTO staff VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
1183
+ for (const s of readJsonl(rel.path("school-staff.jsonl"))) {
1184
+ const entry = add("teacher", s.name, s.clrg, null, [s.school_id], "staff");
1185
+ putStaff.run(s.school_id, s.name, s.clrg, s.role, JSON.stringify(s.credentials), JSON.stringify(s.years), s.from, s.to, s.former ? 1 : 0, s.provisional ? 1 : 0, entry);
1186
+ }
1187
+ const putTd = out.prepare("INSERT INTO td VALUES (?, ?, ?, ?)");
1188
+ for (const t of readJsonl(rel.path("school-teachers.jsonl")))
1189
+ putTd.run(t.school_id, t.clrg, t.via, t.captured);
1190
+ const putSchool = out.prepare("INSERT OR REPLACE INTO school VALUES (?, ?)");
1191
+ for (const r of readJsonl(rel.path("school-registry.jsonl")))
1192
+ putSchool.run(r.school_id, r.display);
1193
+ const src = rel.db("results");
1194
+ const putReg = out.prepare("INSERT INTO register VALUES (?, ?, ?, ?, ?, ?, ?)");
1195
+ const regNames = new Set;
1196
+ for (const r of src.query("SELECT judge_clrg, year, cat, name_printed, region, region_code, country FROM adjudicators ORDER BY judge_clrg, year").all()) {
1197
+ putReg.run(r.judge_clrg, r.year, r.cat, r.name_printed, r.region, r.region_code, r.country);
1198
+ const k = `${r.judge_clrg}\x00${r.name_printed}`;
1199
+ if (regNames.has(k))
1200
+ continue;
1201
+ regNames.add(k);
1202
+ add("adjudicator", r.name_printed, r.judge_clrg, null, [], "register");
1203
+ }
1204
+ const seatPrints = src.query(`SELECT judge_name_printed AS p, judge_clrg AS c, judge_id AS j FROM panels WHERE judge_name_printed IS NOT NULL
1205
+ UNION SELECT judge_name_printed, judge_clrg, judge_id FROM feis_panels ORDER BY 1, 2, 3`).all();
1206
+ for (const s of seatPrints)
1207
+ if (s.c !== null || s.j !== null)
1208
+ add("adjudicator", s.p, s.c, s.j, [], "seat");
1209
+ const comps = new Map;
1210
+ for (const c of src.query("SELECT DISTINCT competition, event, year FROM competitions").all())
1211
+ comps.set(c.competition, { event: c.event, year: c.year });
1212
+ const sat = new Map;
1213
+ const seat = (kind, comp, c, j) => {
1214
+ if (c === null && j === null)
1215
+ return;
1216
+ const at = comps.get(comp);
1217
+ const k = `${c ?? ""}\x00${j ?? ""}\x00${kind}\x00${at?.event ?? ""}\x00${at?.year ?? ""}`;
1218
+ const e = sat.get(k) ?? {
1219
+ c,
1220
+ j,
1221
+ kind,
1222
+ event: at?.event ?? null,
1223
+ year: at?.year ?? null,
1224
+ seats: 0,
1225
+ comps: new Set
1226
+ };
1227
+ e.seats++;
1228
+ e.comps.add(comp);
1229
+ sat.set(k, e);
1230
+ };
1231
+ for (const r of src.query("SELECT slug, judge_clrg, judge_id FROM panels").all())
1232
+ seat("majors", r.slug, r.judge_clrg, r.judge_id);
1233
+ for (const r of src.query("SELECT address, judge_clrg, judge_id FROM feis_panels").all())
1234
+ seat("feis", r.address, r.judge_clrg, r.judge_id);
1235
+ const putSat = out.prepare("INSERT INTO sat VALUES (?, ?, ?, ?, ?, ?, ?)");
1236
+ for (const e of sat.values())
1237
+ putSat.run(e.c, e.j, e.kind, e.event, e.year, e.seats, e.comps.size);
1238
+ for (const s of schema.indexes)
1239
+ if (ASKED2.some((c) => s.endsWith(`(${c})`)))
1240
+ out.run(s);
1241
+ for (const s of INDEXES3)
1242
+ out.run(s);
1243
+ }
1244
+ };
1245
+
1246
+ class StaffFinder2 {
1247
+ rel;
1248
+ constructor(rel) {
1249
+ this.rel = rel;
1250
+ }
1251
+ teacher(asked) {
1252
+ return this.find(asked, "teacher");
1253
+ }
1254
+ adjudicator(asked) {
1255
+ return this.find(asked, "adjudicator");
1256
+ }
1257
+ async find(asked, role) {
1258
+ const db = await this.rel.indexDb("staff");
1259
+ const a = asked.trim();
1260
+ const sources = role === "teacher" ? ["staff"] : ["register", "seat"];
1261
+ const entries = new Map;
1262
+ const eq = db.query("SELECT * FROM entry WHERE entry = ?");
1263
+ let refused;
1264
+ if (/^\d+$/.test(a)) {
1265
+ for (const r of db.query("SELECT entry FROM entry WHERE clrg = ?").all(Number(a)))
1266
+ entries.set(r.entry, "id");
1267
+ } else {
1268
+ const q = roleName(role, a);
1269
+ if (!q.isPerson())
1270
+ refused = "the print reads as no person";
1271
+ else {
1272
+ const who = createIndex2(db, ENCODER2, WHO);
1273
+ for (const { value, row } of who.rows({ surname_key: [q.surnameKey()], nn4: [q.nn4] })) {
1274
+ const p = ENCODER2.decode(row);
1275
+ if (p.role !== role)
1276
+ continue;
1277
+ const e = eq.get(value);
1278
+ if (e === null || !sources.includes(e.source))
1279
+ continue;
1280
+ const level = nameLevel2(q, p.name);
1281
+ if (level !== null)
1282
+ entries.set(value, level);
1283
+ }
1284
+ }
1285
+ }
1286
+ const people = new Map;
1287
+ for (const [id, level] of entries) {
1288
+ const e = eq.get(id);
1289
+ const k = e.clrg !== null ? `c${e.clrg}` : e.judge_id !== null ? `j${e.judge_id}` : `p${e.print}`;
1290
+ const p = people.get(k) ?? {
1291
+ clrg: e.clrg,
1292
+ judge: e.judge_id,
1293
+ level,
1294
+ prints: new Set
1295
+ };
1296
+ p.level = p.level === "id" || level === "id" ? "id" : finer(p.level, level);
1297
+ if (p.judge === null && e.judge_id !== null)
1298
+ p.judge = e.judge_id;
1299
+ p.prints.add(e.print);
1300
+ people.set(k, p);
1301
+ }
1302
+ const out = [...people.values()].map((p) => this.person(db, p.clrg, p.judge, p.level, p.prints));
1303
+ const rank = (l) => l === "id" ? -1 : ["nn0", "nn1", "nn2", "nn3", "nn4", "initial", "middle"].indexOf(l);
1304
+ out.sort((x, y) => rank(x.nameLevel) - rank(y.nameLevel) || y.taught.length + y.sat.length - (x.taught.length + x.sat.length) || (x.clrg ?? Number.MAX_SAFE_INTEGER) - (y.clrg ?? Number.MAX_SAFE_INTEGER));
1305
+ return {
1306
+ tag: this.rel.tag,
1307
+ asked: a,
1308
+ role,
1309
+ arm: out.length === 0 ? "none" : out.length === 1 ? "unique" : "ambiguous",
1310
+ people: out,
1311
+ ...refused !== undefined ? { refused } : {}
1312
+ };
1313
+ }
1314
+ person(db, clrg, judge, level, prints) {
1315
+ const school = db.query("SELECT display FROM school WHERE school_id = ?");
1316
+ const display = (id) => school.get(id)?.display ?? null;
1317
+ const names = new Set(prints);
1318
+ let taught = [];
1319
+ let td = [];
1320
+ let register = [];
1321
+ let satRows = [];
1322
+ if (clrg !== null) {
1323
+ judge ??= db.query("SELECT judge_id FROM entry WHERE clrg = ? AND judge_id IS NOT NULL ORDER BY judge_id LIMIT 1").get(clrg)?.judge_id ?? null;
1324
+ for (const r of db.query("SELECT print FROM entry WHERE clrg = ?").all(clrg))
1325
+ names.add(r.print);
1326
+ taught = this.taughtWhere(db, "clrg = ?", clrg, display);
1327
+ td = db.query("SELECT school_id, via, captured FROM td WHERE clrg = ? ORDER BY captured, school_id").all(clrg).map((t) => ({ ...t, school: display(t.school_id) }));
1328
+ register = db.query("SELECT year, cat, name_printed, region, region_code, country FROM register WHERE clrg = ? ORDER BY year").all(clrg);
1329
+ satRows = db.query("SELECT kind, event, year, seats, comps FROM sat WHERE clrg = ? ORDER BY year, kind, event").all(clrg);
1330
+ } else if (judge !== null) {
1331
+ satRows = db.query("SELECT kind, event, year, seats, comps FROM sat WHERE judge_id = ? ORDER BY year, kind, event").all(judge);
1332
+ } else {
1333
+ for (const p of prints)
1334
+ taught.push(...this.taughtWhere(db, "clrg IS NULL AND name = ?", p, display));
1335
+ }
1336
+ return {
1337
+ clrg,
1338
+ judge_id: judge,
1339
+ nameLevel: level,
1340
+ names: [...names].sort(),
1341
+ taught,
1342
+ td_links: td,
1343
+ register,
1344
+ sat: satRows.map((s) => ({
1345
+ kind: s.kind,
1346
+ event: s.event,
1347
+ year: s.year,
1348
+ seats: s.seats,
1349
+ competitions: s.comps
1350
+ }))
1351
+ };
1352
+ }
1353
+ taughtWhere(db, where, arg, display) {
1354
+ return db.query(`SELECT school_id, name, role, credentials, years, y_from, y_to, former, provisional FROM staff WHERE ${where} ORDER BY school_id`).all(arg).map((s) => ({
1355
+ school_id: s.school_id,
1356
+ school: display(s.school_id),
1357
+ name: s.name,
1358
+ role: s.role,
1359
+ credentials: JSON.parse(s.credentials),
1360
+ years: JSON.parse(s.years),
1361
+ from: s.y_from,
1362
+ to: s.y_to,
1363
+ former: s.former !== 0,
1364
+ provisional: s.provisional !== 0
1365
+ }));
1366
+ }
1367
+ }
1368
+
1369
+ import { createIndex as createIndex3, indexSchema as indexSchema3 } from "@finbheara/domain/noun-index";
1370
+ import {
1371
+ DEFAULT_SCHOOL_NAME_READER,
1372
+ schoolEncoder
1373
+ } from "@finbheara/domain/school";
1374
+ var GENERIC_SCHOOL_READER = {
1375
+ id: "generic",
1376
+ reader: DEFAULT_SCHOOL_NAME_READER
1377
+ };
1378
+ var encoderOf = (r) => schoolEncoder({ reader: r.reader });
1379
+ var TABLE = "school_name";
1380
+ var schoolsIndex = {
1381
+ name: "schools",
1382
+ version: "1",
1383
+ inputs: ["results", "school-registry.jsonl", "school-redirects.jsonl"],
1384
+ says: "every published school print and its ids, read at the school reader's slots",
1385
+ keyParts: (rel) => [`reader=${rel.schoolReader().id}`],
1386
+ build(rel, out) {
1387
+ const choice = rel.schoolReader();
1388
+ const encoder = encoderOf(choice);
1389
+ out.run("CREATE TABLE print_id(print TEXT NOT NULL, school_id TEXT NOT NULL, rows INTEGER NOT NULL, PRIMARY KEY(print, school_id)) WITHOUT ROWID");
1390
+ out.run("CREATE TABLE registry(school_id TEXT PRIMARY KEY, display TEXT NOT NULL, regions TEXT NOT NULL, places TEXT NOT NULL)");
1391
+ out.run("CREATE TABLE redirect(old_id TEXT PRIMARY KEY, new_id TEXT NOT NULL, kind TEXT NOT NULL)");
1392
+ const schema = indexSchema3(encoder, { table: TABLE });
1393
+ out.run(schema.table);
1394
+ const pairs = rel.db("results").query("SELECT trim(school_printed) AS p, school_id AS s, count(*) AS n FROM results WHERE school_id IS NOT NULL GROUP BY 1, 2 ORDER BY 1, 2").all();
1395
+ const putPair = out.prepare("INSERT INTO print_id VALUES (?, ?, ?)");
1396
+ for (const r of pairs)
1397
+ putPair.run(r.p, r.s, r.n);
1398
+ const names = createIndex3(out, encoder, {
1399
+ table: TABLE
1400
+ });
1401
+ let last = null;
1402
+ for (const r of pairs) {
1403
+ if (r.p === last)
1404
+ continue;
1405
+ last = r.p;
1406
+ names.add(choice.reader.schoolName(r.p), r.p);
1407
+ }
1408
+ for (const s of schema.indexes)
1409
+ out.run(s);
1410
+ const putReg = out.prepare("INSERT OR REPLACE INTO registry VALUES (?, ?, ?, ?)");
1411
+ for (const r of readJsonl(rel.path("school-registry.jsonl")))
1412
+ putReg.run(r.school_id, r.display, JSON.stringify([...new Set(r.regions.map((x) => x.region))]), JSON.stringify([...new Set(r.locations.map((x) => x.place))]));
1413
+ const putRed = out.prepare("INSERT OR REPLACE INTO redirect VALUES (?, ?, ?)");
1414
+ for (const r of readJsonl(rel.path("school-redirects.jsonl")))
1415
+ putRed.run(r.old_id, r.new_id, r.kind);
1416
+ }
1417
+ };
1418
+
1419
+ class SchoolResolver2 {
1420
+ rel;
1421
+ constructor(rel) {
1422
+ this.rel = rel;
1423
+ }
1424
+ async resolve(asked, q = {}) {
1425
+ const db = await this.rel.indexDb("schools");
1426
+ const reg = db.query("SELECT display, regions, places FROM registry WHERE school_id = ?");
1427
+ const candidate = (id, by, rows, prints) => {
1428
+ const r = reg.get(id);
1429
+ return {
1430
+ school_id: id,
1431
+ display: r?.display ?? null,
1432
+ rows,
1433
+ by,
1434
+ prints,
1435
+ regions: r === null ? [] : JSON.parse(r.regions),
1436
+ places: r === null ? [] : JSON.parse(r.places)
1437
+ };
1438
+ };
1439
+ const answer = (candidates, extra = {}) => {
1440
+ let cs = candidates.sort((a, b) => b.rows - a.rows || (a.school_id < b.school_id ? -1 : 1));
1441
+ let region;
1442
+ if (q.region !== undefined) {
1443
+ const kept = cs.filter((c) => c.regions.length === 0 || c.regions.includes(q.region));
1444
+ region = { asked: q.region, narrowed: kept.length > 0 && kept.length < cs.length };
1445
+ if (kept.length > 0)
1446
+ cs = kept;
1447
+ }
1448
+ return {
1449
+ tag: this.rel.tag,
1450
+ asked,
1451
+ arm: cs.length === 0 ? "none" : cs.length === 1 ? "unique" : "ambiguous",
1452
+ candidates: cs,
1453
+ ...region !== undefined ? { region } : {},
1454
+ ...extra
1455
+ };
1456
+ };
1457
+ const a = asked.trim();
1458
+ if (reg.get(a) !== null)
1459
+ return answer([candidate(a, "id", 0, [])]);
1460
+ let live = a;
1461
+ const seen = new Set;
1462
+ for (;; ) {
1463
+ const r = db.query("SELECT new_id FROM redirect WHERE old_id = ?").get(live);
1464
+ if (r === null || seen.has(r.new_id))
1465
+ break;
1466
+ seen.add(live);
1467
+ live = r.new_id;
1468
+ }
1469
+ if (live !== a)
1470
+ return answer([candidate(live, "redirect", 0, [])], { redirected: { from: a, to: live } });
1471
+ const byPrints = (prints, by) => {
1472
+ const ids = new Map;
1473
+ const q2 = db.query("SELECT school_id, rows FROM print_id WHERE print = ?");
1474
+ for (const p of prints)
1475
+ for (const r of q2.all(p)) {
1476
+ const e = ids.get(r.school_id) ?? { rows: 0, prints: new Set };
1477
+ e.rows += r.rows;
1478
+ e.prints.add(p);
1479
+ ids.set(r.school_id, e);
1480
+ }
1481
+ return [...ids].map(([id, e]) => candidate(id, by, e.rows, [...e.prints].sort()));
1482
+ };
1483
+ const exact = byPrints([a], "print");
1484
+ if (exact.length > 0)
1485
+ return answer(exact);
1486
+ const choice = this.rel.schoolReader();
1487
+ const read = choice.reader.schoolName(a);
1488
+ if (!read.isSchool())
1489
+ return answer([], {
1490
+ refused: read.refusal?.reason ?? (read.isEmpty() ? "empty" : "not a school")
1491
+ });
1492
+ const names = createIndex3(db, encoderOf(choice), {
1493
+ table: TABLE
1494
+ });
1495
+ for (const level of ["sn3", "realWords"]) {
1496
+ const prints = names.get(read, level);
1497
+ if (prints.length > 0)
1498
+ return answer(byPrints(prints, level));
1499
+ }
1500
+ return answer([]);
1501
+ }
1502
+ }
1503
+
1504
+ function defaultBuilders2() {
1505
+ return [schoolsIndex, feisesIndex, compsIndex, dancersIndex, staffIndex];
1506
+ }
1507
+ var indexDir = (rel) => join(rel.dir, "index");
1508
+ var indexPath2 = (rel, name) => join(indexDir(rel), `${name}.sqlite`);
1509
+ function indexKey2(rel, b) {
1510
+ const files = rel.receipt().files;
1511
+ const parts = [b.name, b.version, ...b.keyParts?.(rel) ?? []];
1512
+ for (const input of b.inputs) {
1513
+ const local = rel.plan(input).local;
1514
+ parts.push(`${local}=${files[local]?.sha256 ?? "?"}`);
1515
+ }
1516
+ return createHash2("sha256").update(parts.join("\x00")).digest("hex");
1517
+ }
1518
+ function keyOf(path) {
1519
+ if (!existsSync(path))
1520
+ return null;
1521
+ try {
1522
+ const db = new Database(path, { readonly: true });
1523
+ try {
1524
+ const r = db.query("SELECT v FROM meta WHERE k = 'key'").get();
1525
+ return r?.v ?? null;
1526
+ } finally {
1527
+ db.close();
1528
+ }
1529
+ } catch {
1530
+ return null;
1531
+ }
1532
+ }
1533
+ function isCurrent2(rel, b) {
1534
+ return keyOf(indexPath2(rel, b.name)) === indexKey2(rel, b);
1535
+ }
1536
+ function buildIndex2(rel, b, o) {
1537
+ const out = indexPath2(rel, b.name);
1538
+ const key = indexKey2(rel, b);
1539
+ if (!o.force && keyOf(out) === key)
1540
+ return out;
1541
+ mkdirSync(indexDir(rel), { recursive: true });
1542
+ const tmp = `${out}.building.${process.pid}`;
1543
+ rmSync(tmp, { force: true });
1544
+ const t0 = performance.now();
1545
+ const db = new Database(tmp, { create: true });
1546
+ try {
1547
+ db.run("PRAGMA journal_mode = OFF");
1548
+ db.run("PRAGMA synchronous = OFF");
1549
+ db.run("CREATE TABLE meta(k TEXT PRIMARY KEY, v TEXT NOT NULL)");
1550
+ db.transaction(() => b.build(rel, db))();
1551
+ const put = db.prepare("INSERT INTO meta(k, v) VALUES (?, ?)");
1552
+ put.run("key", key);
1553
+ put.run("builder", b.name);
1554
+ put.run("version", b.version);
1555
+ put.run("tag", rel.tag);
1556
+ db.run("VACUUM");
1557
+ } catch (e) {
1558
+ db.close();
1559
+ rmSync(tmp, { force: true });
1560
+ throw e;
1561
+ }
1562
+ db.close();
1563
+ renameSync(tmp, out);
1564
+ o.log?.(`index ${rel.tag} ${b.name}: built in ${((performance.now() - t0) / 1000).toFixed(1)} s`);
1565
+ return out;
1566
+ }
1567
+ function listIndexes2(rel) {
1568
+ const dir = indexDir(rel);
1569
+ if (!existsSync(dir))
1570
+ return [];
1571
+ return readdirSync(dir).filter((f) => f.endsWith(".sqlite")).sort().map((f) => {
1572
+ const name = f.slice(0, -".sqlite".length);
1573
+ const path = join(dir, f);
1574
+ const b = rel.builders().find((x) => x.name === name);
1575
+ return {
1576
+ name,
1577
+ path,
1578
+ bytes: statSync(path).size,
1579
+ current: b === undefined ? null : keyOf(path) === indexKey2(rel, b)
1580
+ };
1581
+ });
1582
+ }
1583
+
1584
+ import {
1585
+ copyFileSync,
1586
+ existsSync as existsSync5,
1587
+ linkSync,
1588
+ mkdirSync as mkdirSync4,
1589
+ readFileSync as readFileSync4,
1590
+ readdirSync as readdirSync3,
1591
+ rmSync as rmSync4,
1592
+ statSync as statSync3
1593
+ } from "fs";
1594
+ import { isAbsolute, join as join4, relative, resolve, sep } from "path";
1595
+
1596
+ import { existsSync as existsSync2, readFileSync as readFileSync2, rmSync as rmSync2 } from "fs";
1597
+ import { join as join2 } from "path";
1598
+ var pinPath = (root) => join2(root, "PINNED.json");
1599
+ function readPin2(root) {
1600
+ const p = pinPath(root);
1601
+ if (!existsSync2(p))
1602
+ return null;
1603
+ try {
1604
+ const v = JSON.parse(readFileSync2(p, "utf8"));
1605
+ return typeof v.tag === "string" && parseArchiveTag2(v.tag) !== null ? { tag: v.tag, source: typeof v.source === "string" ? v.source : "unknown" } : null;
1606
+ } catch {
1607
+ return null;
1608
+ }
1609
+ }
1610
+ function writePin(root, pin) {
1611
+ if (parseArchiveTag2(pin.tag) === null)
1612
+ throw new Error(`pin: "${pin.tag}" is not a release tag`);
1613
+ writeJsonAtomic2(pinPath(root), pin);
1614
+ }
1615
+ function clearPin(root) {
1616
+ const p = pinPath(root);
1617
+ if (!existsSync2(p))
1618
+ return false;
1619
+ rmSync2(p);
1620
+ return true;
1621
+ }
1622
+
1623
+ import { Database as Database2 } from "bun:sqlite";
1624
+ import {
1625
+ existsSync as existsSync4,
1626
+ mkdirSync as mkdirSync3,
1627
+ readFileSync as readFileSync3,
1628
+ readdirSync as readdirSync2,
1629
+ renameSync as renameSync2,
1630
+ rmSync as rmSync3,
1631
+ statSync as statSync2
1632
+ } from "fs";
1633
+ import { join as join3 } from "path";
1634
+
1635
+ import { Age as Age3 } from "@finbheara/domain/age";
1636
+ import { compTitle as compTitle2 } from "@finbheara/domain/title";
1637
+ function youngest2(answers) {
1638
+ const key = (a) => {
1639
+ const iv = Age3.from(a.age, { year: null }).interval();
1640
+ return iv === null ? [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY] : [iv.hi, iv.lo];
1641
+ };
1642
+ let best;
1643
+ let bk = [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY];
1644
+ for (const a of answers) {
1645
+ const k = key(a);
1646
+ if (best === undefined || k[0] < bk[0] || k[0] === bk[0] && k[1] > bk[1]) {
1647
+ best = a;
1648
+ bk = k;
1649
+ }
1650
+ }
1651
+ return best;
1652
+ }
1653
+ function dancerResolverOf2(rel) {
1654
+ return rel.dancers;
1655
+ }
1656
+
1657
+ class RegistrationResolver2 {
1658
+ rel;
1659
+ comps;
1660
+ dancers;
1661
+ constructor(rel, comps, dancers) {
1662
+ this.rel = rel;
1663
+ this.comps = comps;
1664
+ this.dancers = dancers;
1665
+ }
1666
+ async dancer(q) {
1667
+ const dancers = this.dancers === undefined ? dancerResolverOf2(this.rel) : this.dancers;
1668
+ let put = q;
1669
+ let comp;
1670
+ const age = q.age;
1671
+ if (age !== undefined && "comp" in age) {
1672
+ const slash = age.comp.lastIndexOf("/");
1673
+ if (slash > 0) {
1674
+ comp = await this.comps.resolve({
1675
+ feis: age.comp.slice(0, slash),
1676
+ code: age.comp.slice(slash + 1),
1677
+ ...age.year !== undefined ? { year: age.year } : {}
1678
+ });
1679
+ if (comp.arm === "unique" && comp.age !== null)
1680
+ put = { ...q, age: { band: comp.age, year: comp.feis.year } };
1681
+ }
1682
+ }
1683
+ return {
1684
+ tag: this.rel.tag,
1685
+ asked: q,
1686
+ put,
1687
+ ...comp !== undefined ? { comp } : {},
1688
+ dancer: dancers === null ? { arm: "unavailable", why: "this release opens no dancer resolver" } : await dancers.resolve(put)
1689
+ };
1690
+ }
1691
+ async resolve(rows) {
1692
+ const seen = new Map;
1693
+ const out = [];
1694
+ for (const row of rows) {
1695
+ const comps = [];
1696
+ for (const code of row.codes) {
1697
+ const title = row.titles?.[code];
1698
+ const key = JSON.stringify([row.feis, code, row.year ?? null, title ?? null]);
1699
+ let a = seen.get(key);
1700
+ if (a === undefined) {
1701
+ a = await this.comps.resolve({
1702
+ feis: row.feis,
1703
+ code,
1704
+ ...row.year !== undefined ? { year: row.year } : {},
1705
+ ...title !== undefined ? { title } : {}
1706
+ });
1707
+ seen.set(key, a);
1708
+ }
1709
+ comps.push(a);
1710
+ }
1711
+ const read = comps.filter((c) => c.arm === "unique" && c.age !== null);
1712
+ const first = youngest2(read);
1713
+ const printed = first !== undefined ? [] : comps.flatMap((c, i) => {
1714
+ const code = row.codes[i];
1715
+ const t = row.titles?.[code];
1716
+ const a = t === undefined ? null : compTitle2(t).age;
1717
+ return a?.isAge() === true && a.kind !== "yob" ? [{ age: a.ag1, year: c.feis.year, code }] : [];
1718
+ });
1719
+ const byTitle = youngest2(printed);
1720
+ const age = first !== undefined ? {
1721
+ band: first.age,
1722
+ year: first.feis.year,
1723
+ code: first.asked.code,
1724
+ from: "comp"
1725
+ } : byTitle !== undefined ? { band: byTitle.age, year: byTitle.year, code: byTitle.code, from: "title" } : null;
1726
+ const bands = [...new Set((first !== undefined ? read : printed).map((c) => c.age))];
1727
+ const order = nameOrderOf(row.order);
1728
+ const asked = {
1729
+ name: row.name,
1730
+ ...order !== undefined ? { order } : {},
1731
+ ...row.school !== undefined ? { school: row.school } : {},
1732
+ ...age !== null ? { age: { band: age.band, year: age.year } } : {},
1733
+ ...row.asOf !== undefined ? { asOf: row.asOf } : {}
1734
+ };
1735
+ const { dancer } = await this.dancer(asked);
1736
+ out.push({
1737
+ tag: this.rel.tag,
1738
+ row,
1739
+ comps,
1740
+ age,
1741
+ ...bands.length > 1 ? { bands } : {},
1742
+ dancer,
1743
+ asked
1744
+ });
1745
+ }
1746
+ return out;
1747
+ }
1748
+ }
1749
+
1750
+ import { spawn } from "child_process";
1751
+ import { createWriteStream as createWriteStream2, existsSync as existsSync3, mkdirSync as mkdirSync2 } from "fs";
1752
+ import { dirname } from "path";
1753
+ import { Readable } from "stream";
1754
+ import { pipeline as pipeline2 } from "stream/promises";
1755
+ function run(cmd, args) {
1756
+ return new Promise((resolve) => {
1757
+ let stdout = "";
1758
+ let stderr = "";
1759
+ let child;
1760
+ try {
1761
+ child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
1762
+ } catch (e) {
1763
+ resolve({ status: null, stdout, stderr: e.message });
1764
+ return;
1765
+ }
1766
+ child.stdout?.on("data", (d) => {
1767
+ stdout += String(d);
1768
+ });
1769
+ child.stderr?.on("data", (d) => {
1770
+ stderr += String(d);
1771
+ });
1772
+ child.on("error", (e) => resolve({ status: null, stdout, stderr: e.message }));
1773
+ child.on("close", (status) => resolve({ status, stdout, stderr }));
1774
+ });
1775
+ }
1776
+ var firstLine = (s) => s.trim().split(`
1777
+ `)[0] ?? "";
1778
+ var ghRemote2 = {
1779
+ name: "gh",
1780
+ async tags(repo) {
1781
+ const r = await run("gh", [
1782
+ "release",
1783
+ "list",
1784
+ "-R",
1785
+ repo,
1786
+ "--limit",
1787
+ "500",
1788
+ "--exclude-drafts",
1789
+ "--json",
1790
+ "tagName",
1791
+ "--jq",
1792
+ ".[].tagName"
1793
+ ]);
1794
+ if (r.status !== 0)
1795
+ throw new Error(`gh release list -R ${repo}: ${firstLine(r.stderr)}`);
1796
+ return r.stdout.split(`
1797
+ `).map((t) => t.trim()).filter((t) => t !== "");
1798
+ },
1799
+ async assets(repo, tag) {
1800
+ const r = await run("gh", ["release", "view", tag, "-R", repo, "--json", "assets"]);
1801
+ if (r.status !== 0)
1802
+ throw new Error(`gh release view ${tag} -R ${repo}: ${firstLine(r.stderr)}`);
1803
+ const v = JSON.parse(r.stdout);
1804
+ return (v.assets ?? []).map((a) => ({ name: a.name, size: a.size, digest: a.digest ?? null }));
1805
+ },
1806
+ async download(repo, tag, name, dest) {
1807
+ const r = await run("gh", [
1808
+ "release",
1809
+ "download",
1810
+ tag,
1811
+ "-R",
1812
+ repo,
1813
+ "-p",
1814
+ name,
1815
+ "-O",
1816
+ dest,
1817
+ "--clobber"
1818
+ ]);
1819
+ if (r.status !== 0 || !existsSync3(dest))
1820
+ throw new Error(`gh release download ${tag} -p ${name}: ${firstLine(r.stderr)}`);
1821
+ }
1822
+ };
1823
+ var API = "https://api.github.com";
1824
+ function nodeWebStream(body) {
1825
+ return body;
1826
+ }
1827
+ function headers(env, accept) {
1828
+ const token = env.GITHUB_TOKEN ?? env.GH_TOKEN;
1829
+ return {
1830
+ accept,
1831
+ "user-agent": "archive-client",
1832
+ "x-github-api-version": "2022-11-28",
1833
+ ...token !== undefined && token !== "" ? { authorization: `Bearer ${token}` } : {}
1834
+ };
1835
+ }
1836
+ function httpsRemote2(env = process.env, fetchImpl = fetch) {
1837
+ const json = async (url) => {
1838
+ const res = await fetchImpl(url, { headers: headers(env, "application/vnd.github+json") });
1839
+ if (!res.ok)
1840
+ throw new Error(`GET ${url}: ${res.status} ${res.statusText}`);
1841
+ return await res.json();
1842
+ };
1843
+ const release = (repo, tag) => json(`${API}/repos/${repo}/releases/tags/${encodeURIComponent(tag)}`);
1844
+ return {
1845
+ name: "https",
1846
+ async tags(repo) {
1847
+ const out = [];
1848
+ for (let page = 1;page <= 10; page++) {
1849
+ const rows = await json(`${API}/repos/${repo}/releases?per_page=100&page=${page}`);
1850
+ for (const r of rows)
1851
+ if (!r.draft)
1852
+ out.push(r.tag_name);
1853
+ if (rows.length < 100)
1854
+ break;
1855
+ }
1856
+ return out;
1857
+ },
1858
+ async assets(repo, tag) {
1859
+ return (await release(repo, tag)).assets.map((a) => ({
1860
+ name: a.name,
1861
+ size: a.size,
1862
+ digest: a.digest ?? null
1863
+ }));
1864
+ },
1865
+ async download(repo, tag, name, dest) {
1866
+ const a = (await release(repo, tag)).assets.find((x) => x.name === name);
1867
+ if (a === undefined)
1868
+ throw new Error(`${tag} has no asset ${name}`);
1869
+ const res = await fetchImpl(a.url, { headers: headers(env, "application/octet-stream") });
1870
+ if (!res.ok || res.body === null)
1871
+ throw new Error(`GET ${a.url}: ${res.status} ${res.statusText}`);
1872
+ mkdirSync2(dirname(dest), { recursive: true });
1873
+ await pipeline2(Readable.fromWeb(nodeWebStream(res.body)), createWriteStream2(dest));
1874
+ }
1875
+ };
1876
+ }
1877
+ function fallbackRemote2(first, second) {
1878
+ const both = (f) => async (...a) => {
1879
+ try {
1880
+ return await f(first)(...a);
1881
+ } catch (e1) {
1882
+ try {
1883
+ return await f(second)(...a);
1884
+ } catch (e2) {
1885
+ throw new Error(`${first.name}: ${e1.message}; ${second.name}: ${e2.message}`);
1886
+ }
1887
+ }
1888
+ };
1889
+ return {
1890
+ name: `${first.name}+${second.name}`,
1891
+ tags: both((r) => r.tags.bind(r)),
1892
+ assets: both((r) => r.assets.bind(r)),
1893
+ download: both((r) => r.download.bind(r))
1894
+ };
1895
+ }
1896
+ var installed = null;
1897
+ function setRemote2(r) {
1898
+ const was = installed;
1899
+ installed = r;
1900
+ return was;
1901
+ }
1902
+ function defaultRemote() {
1903
+ return installed ?? fallbackRemote2(ghRemote2, httpsRemote2());
1904
+ }
1905
+
1906
+ function tagDir(root, tag) {
1907
+ if (parseArchiveTag2(tag) === null)
1908
+ throw new Error(`archive-client: "${tag}" is not a release tag (archive-YYYY.MM.DD[-N[.k]])`);
1909
+ return join3(root, tag);
1910
+ }
1911
+ var receiptPath = (dir) => join3(dir, ".client.json");
1912
+ function readReceipt(dir) {
1913
+ const p = receiptPath(dir);
1914
+ if (!existsSync4(p))
1915
+ return null;
1916
+ try {
1917
+ return JSON.parse(readFileSync3(p, "utf8"));
1918
+ } catch {
1919
+ return null;
1920
+ }
1921
+ }
1922
+ async function resolveTag2(spec, opts = {}) {
1923
+ const s = typeof spec === "object" ? spec : parseReleaseSpec2(spec);
1924
+ if (typeof s === "string")
1925
+ throw new Error(s);
1926
+ const root = cacheRoot2(opts);
1927
+ if (s.kind === "pinned") {
1928
+ const pin = readPin2(root);
1929
+ return resolveTag2(pin === null ? { kind: "latest" } : { kind: "tag", tag: pin.tag }, opts);
1930
+ }
1931
+ if (s.kind === "tag" && existsSync4(join3(tagDir(root, s.tag), "MANIFEST.json")))
1932
+ return s.tag;
1933
+ const repo = requireRepo2(opts);
1934
+ const tags = await (opts.remote ?? defaultRemote()).tags(repo);
1935
+ const tag = pickTag2(tags, s);
1936
+ if (tag === null)
1937
+ throw new Error(`archive-client: ${repo} publishes no release ${s.kind === "tag" ? s.tag : s.kind === "cut" ? `of cut ${s.cut}${s.fraction > 0 ? `.${s.fraction}` : ""}` : "at all"}`);
1938
+ return tag;
1939
+ }
1940
+ function dropIfEmpty(dir) {
1941
+ if (existsSync4(dir) && readdirSync2(dir).length === 0)
1942
+ rmSync3(dir, { recursive: true });
1943
+ }
1944
+
1945
+ class Release2 {
1946
+ tag;
1947
+ cut;
1948
+ fraction;
1949
+ dir;
1950
+ root;
1951
+ manifest;
1952
+ feises;
1953
+ schools;
1954
+ dancers;
1955
+ results;
1956
+ majors;
1957
+ staff;
1958
+ comps;
1959
+ registrations;
1960
+ opts;
1961
+ handles = new Map;
1962
+ remoteAssets = null;
1963
+ constructor(tag, root, manifest, opts) {
1964
+ const t = parseArchiveTag2(tag);
1965
+ this.tag = tag;
1966
+ this.cut = t?.cut;
1967
+ this.fraction = t?.fraction ?? 0;
1968
+ this.root = root;
1969
+ this.dir = tagDir(root, tag);
1970
+ this.manifest = manifest;
1971
+ this.opts = opts;
1972
+ this.feises = new FeisFinder2(this);
1973
+ this.schools = new SchoolResolver2(this);
1974
+ this.dancers = new DancerResolver2(this);
1975
+ this.results = new ResultFinder2(this);
1976
+ this.majors = new MajorsFinder2(this);
1977
+ this.staff = new StaffFinder2(this);
1978
+ this.comps = new CompResolver2(this);
1979
+ this.registrations = new RegistrationResolver2(this, this.comps, opts.dancerResolver);
1980
+ }
1981
+ receipt() {
1982
+ return readReceipt(this.dir) ?? { tag: this.tag, source: "unknown", files: {} };
1983
+ }
1984
+ plan(asset) {
1985
+ return planAsset2(asset, this.manifest);
1986
+ }
1987
+ has(asset) {
1988
+ return existsSync4(join3(this.dir, this.plan(asset).local));
1989
+ }
1990
+ path(asset) {
1991
+ const p = join3(this.dir, this.plan(asset).local);
1992
+ if (!existsSync4(p))
1993
+ throw new Error(`archive-client: ${this.tag} has no ${asset} in the cache: fetch it first (\`await rel.fetch("${asset}")\`, or \`fetch --release ${this.tag} --assets ${asset}\`)`);
1994
+ return p;
1995
+ }
1996
+ async fetch(...assets) {
1997
+ const out = [];
1998
+ for (const a of assets)
1999
+ out.push(await this.fetchOne(a));
2000
+ return out;
2001
+ }
2002
+ async listed() {
2003
+ if (this.remoteAssets === null)
2004
+ this.remoteAssets = await (this.opts.remote ?? defaultRemote()).assets(requireRepo2(this.opts), this.tag);
2005
+ return this.remoteAssets;
2006
+ }
2007
+ async fetchOne(asset) {
2008
+ const plan = this.plan(asset);
2009
+ const dest = join3(this.dir, plan.local);
2010
+ const have = this.receipt().files[plan.local];
2011
+ if (existsSync4(dest) && have !== undefined)
2012
+ return have;
2013
+ const log = this.opts.log ?? (() => {});
2014
+ if (existsSync4(dest) && plan.contentSha256 !== null) {
2015
+ const sha = await sha256File2(dest);
2016
+ if (sha === plan.contentSha256) {
2017
+ const adopted = {
2018
+ from: plan.local,
2019
+ sha256: sha,
2020
+ bytes: statSync2(dest).size,
2021
+ verified: ["manifest"]
2022
+ };
2023
+ log(`adopt ${this.tag} ${plan.local} (the manifest's sha256)`);
2024
+ this.record(plan.local, adopted);
2025
+ return adopted;
2026
+ }
2027
+ }
2028
+ const listed = await this.listed();
2029
+ const pub = plan.published.map((n) => listed.find((a) => a.name === n)).find((a) => a !== undefined);
2030
+ if (pub === undefined)
2031
+ throw new Error(`archive-client: ${this.tag} publishes no ${plan.published.join(" or ")} (asked: ${asset})`);
2032
+ const part = join3(this.dir, ".part");
2033
+ mkdirSync3(part, { recursive: true });
2034
+ const raw = join3(part, pub.name);
2035
+ log(`fetch ${this.tag} ${pub.name} (${pub.size} bytes)`);
2036
+ await (this.opts.remote ?? defaultRemote()).download(requireRepo2(this.opts), this.tag, pub.name, raw);
2037
+ const landed = await land(raw, pub, plan, dest, this.opts, log);
2038
+ this.record(plan.local, landed);
2039
+ dropIfEmpty(part);
2040
+ return landed;
2041
+ }
2042
+ record(local, f) {
2043
+ const r = this.receipt();
2044
+ r.files[local] = f;
2045
+ writeJsonAtomic2(receiptPath(this.dir), r);
2046
+ }
2047
+ db(asset) {
2048
+ let h = this.handles.get(asset);
2049
+ if (h === undefined) {
2050
+ h = new Database2(this.path(asset), { readonly: true });
2051
+ this.handles.set(asset, h);
2052
+ }
2053
+ return h;
2054
+ }
2055
+ schoolReader() {
2056
+ return this.opts.schoolReader ?? GENERIC_SCHOOL_READER;
2057
+ }
2058
+ builders() {
2059
+ return this.opts.builders ?? defaultBuilders2();
2060
+ }
2061
+ async index(name, o = {}) {
2062
+ const b = this.builders().find((x) => x.name === name);
2063
+ if (b === undefined)
2064
+ throw new Error(`archive-client: no index "${name}" (known: ${this.builders().map((x) => x.name).join(", ")})`);
2065
+ await this.fetch(...b.inputs);
2066
+ return buildIndex2(this, b, { force: o.force ?? false, log: this.opts.log });
2067
+ }
2068
+ async indexDb(name) {
2069
+ const key = `index:${name}`;
2070
+ const open = this.handles.get(key);
2071
+ if (open !== undefined)
2072
+ return open;
2073
+ const h = new Database2(await this.index(name), { readonly: true });
2074
+ this.handles.set(key, h);
2075
+ return h;
2076
+ }
2077
+ close() {
2078
+ for (const h of this.handles.values())
2079
+ h.close();
2080
+ this.handles.clear();
2081
+ }
2082
+ [Symbol.dispose]() {
2083
+ this.close();
2084
+ }
2085
+ }
2086
+ async function land(raw, pub, plan, dest, opts, log) {
2087
+ const verified = [];
2088
+ const host = sha256OfDigest(pub.digest);
2089
+ if (host !== null) {
2090
+ const got = await sha256File2(raw);
2091
+ if (got !== host) {
2092
+ rmSync3(raw, { force: true });
2093
+ throw new Error(`archive-client: ${pub.name} does not match the host's digest (sha256 ${got}, the host says ${host})`);
2094
+ }
2095
+ verified.push("host-digest");
2096
+ }
2097
+ let landedAt = raw;
2098
+ let sha;
2099
+ if (plan.decompress && pub.name.endsWith(".zst")) {
2100
+ landedAt = `${raw.slice(0, -".zst".length)}.plain`;
2101
+ log(`decompress ${pub.name}`);
2102
+ sha = await unzstdTo2(raw, landedAt);
2103
+ } else
2104
+ sha = await sha256File2(raw);
2105
+ if (plan.contentSha256 !== null) {
2106
+ if (sha !== plan.contentSha256) {
2107
+ rmSync3(landedAt, { force: true });
2108
+ throw new Error(`archive-client: ${plan.local} does not match MANIFEST.json (sha256 ${sha}, the manifest says ${plan.contentSha256})`);
2109
+ }
2110
+ verified.push("manifest");
2111
+ }
2112
+ if (verified.length === 0 && opts.strict === true) {
2113
+ rmSync3(landedAt, { force: true });
2114
+ throw new Error(`archive-client: nothing verifies ${pub.name} (no host digest, no manifest hash) and strict is set`);
2115
+ }
2116
+ renameSync2(landedAt, dest);
2117
+ if (landedAt !== raw) {
2118
+ if (opts.keepCompressed === true)
2119
+ renameSync2(raw, `${dest}.zst`);
2120
+ else
2121
+ rmSync3(raw, { force: true });
2122
+ }
2123
+ return { from: pub.name, sha256: sha, bytes: statSync2(dest).size, verified };
2124
+ }
2125
+ async function openRelease2(spec = "pinned", opts = {}) {
2126
+ const root = cacheRoot2(opts);
2127
+ const tag = await resolveTag2(spec, opts);
2128
+ const dir = tagDir(root, tag);
2129
+ const manifestPath = join3(dir, "MANIFEST.json");
2130
+ if (!existsSync4(manifestPath)) {
2131
+ const remote = opts.remote ?? defaultRemote();
2132
+ const repo = requireRepo2(opts);
2133
+ const listed = await remote.assets(repo, tag);
2134
+ const pub = listed.find((a) => a.name === "MANIFEST.json");
2135
+ if (pub === undefined)
2136
+ throw new Error(`archive-client: ${tag} publishes no MANIFEST.json`);
2137
+ const part = join3(dir, ".part");
2138
+ mkdirSync3(part, { recursive: true });
2139
+ const raw = join3(part, "MANIFEST.json");
2140
+ await remote.download(repo, tag, "MANIFEST.json", raw);
2141
+ const landed = await land(raw, pub, planAsset2("MANIFEST.json", null), manifestPath, opts, () => {});
2142
+ writeJsonAtomic2(receiptPath(dir), {
2143
+ tag,
2144
+ source: repo,
2145
+ files: { "MANIFEST.json": landed }
2146
+ });
2147
+ dropIfEmpty(part);
2148
+ }
2149
+ const manifest = JSON.parse(readFileSync3(manifestPath, "utf8"));
2150
+ const rel = new Release2(tag, root, manifest, opts);
2151
+ await rel.fetch(...opts.assets ?? ["results"]);
2152
+ return rel;
2153
+ }
2154
+ function openCached2(tag, opts = {}) {
2155
+ const root = cacheRoot2(opts);
2156
+ const p = join3(tagDir(root, tag), "MANIFEST.json");
2157
+ if (!existsSync4(p))
2158
+ return null;
2159
+ return new Release2(tag, root, JSON.parse(readFileSync3(p, "utf8")), opts);
2160
+ }
2161
+
2162
+ function duBytes(p) {
2163
+ if (!existsSync5(p))
2164
+ return 0;
2165
+ const s = statSync3(p);
2166
+ if (!s.isDirectory())
2167
+ return s.size;
2168
+ let n = 0;
2169
+ for (const e of readdirSync3(p))
2170
+ n += duBytes(join4(p, e));
2171
+ return n;
2172
+ }
2173
+
2174
+ class ReleaseManager2 {
2175
+ opts;
2176
+ root;
2177
+ constructor(opts = {}) {
2178
+ this.opts = opts;
2179
+ this.root = cacheRoot2(opts);
2180
+ }
2181
+ cachedTags() {
2182
+ if (!existsSync5(this.root))
2183
+ return [];
2184
+ return readdirSync3(this.root).filter((t) => parseArchiveTag2(t) !== null && existsSync5(join4(this.root, t, "MANIFEST.json"))).sort(compareTagsNewestFirst2);
2185
+ }
2186
+ cached(tag) {
2187
+ const r = openCached2(tag, this.opts);
2188
+ if (r === null)
2189
+ throw new Error(`archive-client: ${tag} is not in the cache (${this.root})`);
2190
+ return r;
2191
+ }
2192
+ async list(o = {}) {
2193
+ const pin = readPin2(this.root);
2194
+ const cached = this.cachedTags().map((tag) => {
2195
+ const rel = this.cached(tag);
2196
+ const receipt = rel.receipt();
2197
+ return {
2198
+ tag,
2199
+ cut: rel.cut,
2200
+ source: receipt.source,
2201
+ pinned: pin?.tag === tag,
2202
+ bytes: duBytes(rel.dir),
2203
+ files: Object.entries(receipt.files).map(([name, f]) => ({ name, bytes: f.bytes, verified: f.verified })).sort((a, b) => a.name < b.name ? -1 : 1),
2204
+ indexes: listIndexes2(rel)
2205
+ };
2206
+ });
2207
+ const out = { root: this.root, repo: repoOf2(this.opts), pin, cached };
2208
+ if (o.remote === true)
2209
+ out.published = (await (this.opts.remote ?? defaultRemote()).tags(requireRepo2(this.opts))).filter((t) => parseArchiveTag2(t) !== null).sort(compareTagsNewestFirst2);
2210
+ return out;
2211
+ }
2212
+ fetch(spec, o = {}) {
2213
+ return openRelease2(spec, { ...this.opts, assets: o.assets ?? ["results"] });
2214
+ }
2215
+ async pin(spec) {
2216
+ const rel = await openRelease2(spec, { ...this.opts, assets: [] });
2217
+ const pin = { tag: rel.tag, source: rel.receipt().source };
2218
+ writePin(this.root, pin);
2219
+ return pin;
2220
+ }
2221
+ unpin() {
2222
+ return clearPin(this.root);
2223
+ }
2224
+ current() {
2225
+ const pin = readPin2(this.root);
2226
+ return { pin, cached: pin !== null && openCached2(pin.tag, this.opts) !== null };
2227
+ }
2228
+ config(patch) {
2229
+ if (patch.repo !== undefined)
2230
+ repoOf2({ ...this.opts, repo: patch.repo });
2231
+ return writeConfig2(this.root, patch);
2232
+ }
2233
+ readConfig() {
2234
+ return readConfig2(this.root);
2235
+ }
2236
+ async add(dir, tag) {
2237
+ if (parseArchiveTag2(tag) === null)
2238
+ throw new Error(`add: "${tag}" is not a release tag`);
2239
+ const mf = join4(dir, "MANIFEST.json");
2240
+ if (!existsSync5(mf))
2241
+ throw new Error(`add: ${dir} holds no MANIFEST.json`);
2242
+ const dest = tagDir(this.root, tag);
2243
+ if (existsSync5(join4(dest, "MANIFEST.json")))
2244
+ throw new Error(`add: ${tag} is already in the cache (${dest}); \`rm ${tag}\` first`);
2245
+ const manifest = JSON.parse(readFileSync4(mf, "utf8"));
2246
+ const part = join4(dest, ".part");
2247
+ mkdirSync4(part, { recursive: true });
2248
+ const receipt = { tag, source: `local:${dir}`, files: {} };
2249
+ const log = this.opts.log ?? (() => {});
2250
+ for (const name of readdirSync3(dir).sort()) {
2251
+ const src = join4(dir, name);
2252
+ if (!statSync3(src).isFile())
2253
+ continue;
2254
+ const raw = join4(part, name);
2255
+ try {
2256
+ linkSync(src, raw);
2257
+ } catch {
2258
+ copyFileSync(src, raw);
2259
+ }
2260
+ const base = name.replace(/\.sqlite(\.zst)?$/, "");
2261
+ const asked = base !== name && isDatabase(base) ? base : name;
2262
+ const plan = planAsset2(asked, manifest);
2263
+ const landed = await land(raw, { name, digest: null }, plan, join4(dest, plan.local), this.opts, log);
2264
+ receipt.files[plan.local] = landed;
2265
+ }
2266
+ rmSync4(part, { recursive: true, force: true });
2267
+ writeJsonAtomic2(receiptPath(dest), receipt);
2268
+ const rel = openCached2(tag, this.opts);
2269
+ if (rel === null)
2270
+ throw new Error(`add: ${tag} did not land`);
2271
+ return rel;
2272
+ }
2273
+ async index(spec, names = [], o = {}) {
2274
+ const rel = await openRelease2(spec, { ...this.opts, assets: [] });
2275
+ const want = names.length > 0 ? names : rel.builders().map((b) => b.name);
2276
+ const built = [];
2277
+ for (const name of want) {
2278
+ const b = rel.builders().find((x) => x.name === name);
2279
+ if (b === undefined)
2280
+ throw new Error(`index: no index "${name}"`);
2281
+ await rel.fetch(...b.inputs);
2282
+ built.push({
2283
+ name,
2284
+ path: buildIndex2(rel, b, { force: o.force ?? false, log: this.opts.log })
2285
+ });
2286
+ }
2287
+ rel.close();
2288
+ return { tag: rel.tag, built };
2289
+ }
2290
+ plan(dryRun) {
2291
+ return { dryRun, remove: [], keep: [], bytes: 0 };
2292
+ }
2293
+ planRemove(p, path, why) {
2294
+ const rel = relative(resolve(this.root), resolve(path));
2295
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
2296
+ throw new Error(`archive-client: refusing to remove ${path}: not inside ${this.root}`);
2297
+ p.remove.push({ path: resolve(path), bytes: duBytes(path), why });
2298
+ }
2299
+ apply(p) {
2300
+ p.bytes = p.remove.reduce((n, r) => n + r.bytes, 0);
2301
+ if (!p.dryRun)
2302
+ for (const r of p.remove)
2303
+ rmSync4(r.path, { recursive: true, force: true });
2304
+ return p;
2305
+ }
2306
+ prune(o = {}) {
2307
+ const keep = o.keep ?? 1;
2308
+ if (!Number.isInteger(keep) || keep < 0)
2309
+ throw new Error(`prune: --keep ${keep} is not a count`);
2310
+ const pin = readPin2(this.root);
2311
+ const p = this.plan(o.dryRun ?? false);
2312
+ this.cachedTags().forEach((tag, i) => {
2313
+ if (tag === pin?.tag)
2314
+ p.keep.push({ tag, why: "pinned" });
2315
+ else if (i < keep)
2316
+ p.keep.push({ tag, why: `among the ${keep} newest` });
2317
+ else
2318
+ this.planRemove(p, tagDir(this.root, tag), `older than the ${keep} newest`);
2319
+ });
2320
+ return this.apply(p);
2321
+ }
2322
+ remove(tag, o = {}) {
2323
+ const path = tagDir(this.root, tag);
2324
+ if (!existsSync5(path))
2325
+ throw new Error(`${tag} is not in the cache`);
2326
+ if (readPin2(this.root)?.tag === tag)
2327
+ throw new Error(`${tag} is pinned: \`unpin\` first`);
2328
+ const p = this.plan(o.dryRun ?? false);
2329
+ this.planRemove(p, path, "asked");
2330
+ return this.apply(p);
2331
+ }
2332
+ dropIndexes(tag, o = {}) {
2333
+ const p = this.plan(o.dryRun ?? false);
2334
+ for (const t of tag === undefined ? this.cachedTags() : [tag]) {
2335
+ const path = indexDir({ dir: tagDir(this.root, t) });
2336
+ if (existsSync5(path))
2337
+ this.planRemove(p, path, `${t}'s indexes`);
2338
+ }
2339
+ return this.apply(p);
2340
+ }
2341
+ async verify(tag) {
2342
+ const rel = this.cached(tag);
2343
+ const bad = [];
2344
+ for (const [name, f] of Object.entries(rel.receipt().files)) {
2345
+ const p = join4(rel.dir, name);
2346
+ const got = existsSync5(p) ? await sha256File2(p) : "missing";
2347
+ if (got !== f.sha256)
2348
+ bad.push({ file: name, want: f.sha256, got });
2349
+ }
2350
+ return bad;
2351
+ }
2352
+ }
2353
+ function releases2(opts = {}) {
2354
+ return new ReleaseManager2(opts);
2355
+ }
2356
+
2357
+ export { DATABASES2, planAsset2, sha256File2, unzstdTo2, NAME_LEVELS2, nameLevel2, FEIS_LOCAL_ID_FLOOR2, ResultFinder2, MajorsFinder2, nameOrderOf, DancerResolver2, canonicalOf2, CompResolver2, NO_CALENDAR2, FeisFinder2, StaffFinder2, SchoolResolver2, defaultBuilders2, indexPath2, indexKey2, isCurrent2, buildIndex2, listIndexes2, readPin2, youngest2, dancerResolverOf2, RegistrationResolver2, ghRemote2, httpsRemote2, fallbackRemote2, setRemote2, resolveTag2, Release2, openRelease2, openCached2, ReleaseManager2, releases2 };