@finbheara/archive-client 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Finbheara Systems
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,359 @@
1
- # Temporary Holding Version
1
+ # @finbheara/archive-client
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Open a versioned release of an Irish dance results dataset and work with it locally: download,
4
+ verify, decompress and cache it by tag, keep a user-local cache of releases, and resolve feises,
5
+ schools, competitions, registration lists, dancers, results, majors, teachers and
6
+ adjudicators against one. A command line, `idq`, is a thin wrapper over the same calls.
7
+
8
+ Bun 1.4.2 or later (the databases open through `bun:sqlite`). MIT.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ bun add @finbheara/archive-client
14
+ ```
15
+
16
+ Bun only: the databases open through `bun:sqlite`. The package ships built JavaScript and its
17
+ type declarations (`.d.ts`), so a TypeScript project needs Bun's types (`"types": ["bun"]` or
18
+ `["bun-types"]`) and nothing else from the client: no `allowImportingTsExtensions`, no
19
+ `preserveSymlinks`. The `idq` command line comes with it: in a project that installed it,
20
+ `bunx idq <verb>`.
21
+
22
+ The version is 0.x while the API settles: a minor bump may break, so pin a minor
23
+ (`"@finbheara/archive-client": "0.1.x"`).
24
+
25
+ ## Configuration
26
+
27
+ The package carries no data and names no releases repository: it reads releases from a
28
+ repository you name, your own.
29
+
30
+ | what | option | environment | user-local |
31
+ | --- | --- | --- | --- |
32
+ | the releases repository (`owner/name`) | `repo` | `ARCHIVE_CLIENT_REPO` | `idq config repo <owner/name>` |
33
+ | the cache root | `cache` | `ARCHIVE_CLIENT_CACHE` | |
34
+
35
+ The option wins over the environment, the environment over the user-local config. With no
36
+ repository, a call that needs the network refuses by name. With no cache set, the cache is
37
+ `archive-client` in your home cache directory.
38
+
39
+ A private releases repository needs credentials: `gh` logged in to an account that can read it,
40
+ or `GITHUB_TOKEN` (or `GH_TOKEN`) holding a token that can.
41
+
42
+ ## Where the releases come from
43
+
44
+ A release is a GitHub release of some repository: a `MANIFEST.json`, the databases
45
+ (`results.sqlite.zst`, `majors.sqlite.zst`) and the side files (jsonl, parquet). The package names
46
+ no repository. Give one, in order of precedence:
47
+
48
+ - the `repo` option (`owner/name`)
49
+ - the `ARCHIVE_CLIENT_REPO` environment variable
50
+ - the user-local config: `idq config repo <owner/name>`
51
+
52
+ `gh` is used first (it reads your own login), and the public API over HTTPS where `gh` is missing
53
+ or fails (`GITHUB_TOKEN` or `GH_TOKEN` as a bearer token, needed where the repository is not public).
54
+
55
+ ## Open a release
56
+
57
+ ```ts
58
+ import { openRelease } from "@finbheara/archive-client";
59
+
60
+ const rel = await openRelease("latest", { assets: ["results", "majors"] });
61
+ rel.tag; // the release tag
62
+ rel.cut; // the cut number the tag carries
63
+ rel.manifest; // its MANIFEST.json
64
+ rel.db("results").query("SELECT count(*) AS n FROM results").get(); // read-only handle
65
+ await rel.fetch("school-registry.jsonl"); // any other asset, on demand
66
+ rel.path("school-registry.jsonl"); // its cached file
67
+ rel.close();
68
+ ```
69
+
70
+ A spec is `pinned` (the default: the user-local pin, else the latest), `latest`, a cut number
71
+ (`42`, or `42.1` for a fraction) or a tag (`archive-YYYY.MM.DD-N`).
72
+
73
+ EVERY FILE IS CHECKED before it lands: the downloaded bytes against the host's own sha256 digest,
74
+ and the content against the manifest (a database's decompressed bytes against
75
+ `databases.<name>.sha256`, a parquet file against `parquet.files[<name>].sha256`). A mismatch
76
+ refuses and leaves nothing in place. A file nothing vouches for is kept and its receipt says so
77
+ (`strict: true` refuses it). A database is decompressed in process, streamed, never through a
78
+ `zstd` binary.
79
+
80
+ ## The cache
81
+
82
+ One directory per tag under the cache root: the `cache` option, else `ARCHIVE_CLIENT_CACHE`,
83
+ else `archive-client` in your home cache directory.
84
+
85
+ <root>/<tag>/MANIFEST.json
86
+ <root>/<tag>/results.sqlite decompressed, verified
87
+ <root>/<tag>/<asset> any other file, as published
88
+ <root>/<tag>/.client.json the receipt: every file's sha256 and what verified it
89
+ <root>/<tag>/index/<name>.sqlite the per-tag indexes
90
+ <root>/PINNED.json the user-local pin
91
+ <root>/config.json the user-local config
92
+
93
+ A file already in place with no receipt (laid out by hand) is adopted where the manifest's hash
94
+ vouches for it, and fetched again where it does not. A published release never changes, so a
95
+ verified file is never fetched twice.
96
+
97
+ ## The release manager
98
+
99
+ ```ts
100
+ import { releases } from "@finbheara/archive-client";
101
+
102
+ const m = releases();
103
+ await m.list({ remote: true }); // cached releases, sizes, files, indexes, the pin
104
+ await m.fetch("latest", { assets: ["results"] });
105
+ await m.pin("42"); // a bare openRelease() now opens it
106
+ m.unpin();
107
+ await m.add("<dir>", "archive-YYYY.MM.DD-N"); // a release built locally, under its own tag
108
+ await m.index("pinned"); // build every index
109
+ m.prune({ keep: 2, dryRun: true }); // the plan: what would go, and its bytes
110
+ m.remove("<tag>"); // refuses the pinned release
111
+ m.dropIndexes(); // the index files only
112
+ await m.verify("<tag>"); // re-hash every cached file against its receipt
113
+ ```
114
+
115
+ Every removal answers its plan first (`dryRun`), keeps the pinned release, and touches nothing
116
+ outside the cache root.
117
+
118
+ ## The indexes
119
+
120
+ The release databases carry a primary key per table and nothing more, so lookups build side
121
+ indexes, one SQLite file per index per tag, each keyed on its builder's version and the sha256 of
122
+ every input it read. A file whose key is stale is rebuilt, never served. Four ship here, and a
123
+ caller may hand its own (`builders` option, an `IndexBuilder`):
124
+
125
+ - `schools`: every published school print with the ids its rows carry, filed into a
126
+ `@finbheara/domain` `schoolEncoder` noun index, with the registry and the retired ids
127
+ - `feises`: every feis by id, platform, host code, series and name, with its competition and
128
+ result counts and the regions its competitions read
129
+ - `dancers` (keyed on the names and domain package versions too): every published dancer print at the names package's keys (one row per print and
130
+ reading, a `@finbheara/domain` `personEncoder` noun index), the dancers each print was
131
+ published under, each dancer's schools and years
132
+ - `staff` (keyed the same way): teachers and adjudicators by name (a `roleEncoder` noun index) and TD number: the
133
+ schools' staff lists and TD links, the adjudicator register, and the seats each sat
134
+ - `comps`: every competition by its feis and the feis's own code, with its series and year
135
+
136
+ ## Feises
137
+
138
+ ```ts
139
+ await rel.feises.find({ platform: "feisweb", code: "ABC24" }); // a source or its id token
140
+ await rel.feises.find({ id: "<feis, series instance or brand id>" }); // a retired id followed
141
+ await rel.feises.find({ name: "*Spring*", year: 2024 });
142
+ ```
143
+
144
+ One answer per feis id: dates, location, series ids, regions, and how many competitions, rows
145
+ and captures it has.
146
+
147
+ ```ts
148
+ const s = await rel.feises.series("Ardvarna Spring"); // a name, a host code ("fweb:ABC", "fweb:ABC26"), any feis id
149
+ s.arm; // "unique" | "ambiguous" | "none"
150
+ s.series_id; // the series, with s.name, s.host ("feisweb"), s.years, s.instances
151
+ s.next; // the next scheduled instance where the release knows one, else null with s.next_why
152
+ ```
153
+
154
+ A series is asked by any id of it (a feis, instance or brand id, a retired one followed), by a
155
+ host code with or without its year (held codes `<stem><yy>` whose two digits are their own year),
156
+ or by a name (a glob, or a plain word read as `*word*`). Two series answer `ambiguous` with both.
157
+ A release holds a feis-year once its results are captured and carries no calendar, so `next` is
158
+ `null` today, with the reason.
159
+
160
+ ## Schools
161
+
162
+ ```ts
163
+ const a = await rel.schools.resolve("Ardvarna Academy of Irish Dance (Calgary, AB)", { region: "WCRO" });
164
+ a.arm; // "unique" | "ambiguous" | "none"
165
+ a.candidates; // [{ school_id, display, rows, by, prints, regions, places }]
166
+ ```
167
+
168
+ The rungs, the first that answers wins: a school id, a retired id followed to the live one, the
169
+ print exactly as the release published it, then the print read by `@finbheara/domain`'s school
170
+ reader at its filing key (`sn3`) and at its real words. `ambiguous` and `none` are answers: a
171
+ best guess is never promoted to `unique`. `region` drops only a candidate the registry places in
172
+ other regions, and keeps one it places nowhere. A print the reader refuses (a placeholder)
173
+ answers `none` with the reason.
174
+
175
+ Every answer carries the release tag it came from.
176
+
177
+ ## Dancers
178
+
179
+ ```ts
180
+ const r = await rel.dancers.resolve({
181
+ name: "Quellan, A", order: "surname-first", // initials and partial given names are first-class
182
+ school: "Ardvarna Academy", // a print or an id, through rel.schools
183
+ age: { band: "U10", year: 2026 }, // or { yob: 2016 }, or { comp: "<competition>" }
184
+ asOf: "2026-10-17", // the school as of then
185
+ });
186
+ r.arm; // "unique" | "ambiguous" | "none"
187
+ r.candidates; // [{ dancer_id, display, scope, score, evidence: { nameLevel, age, school }, prints, ... }]
188
+ r.excluded; // the candidates an age contradiction set aside (why: "age")
189
+ r.preferred; // NON-BINDING: { dancer_id, why } where the school singles one out; never moves arm
190
+ r.unlinked; // rows printing a matched name that the release linked to no dancer
191
+
192
+ await rel.dancers.search({ surname: "Quellan" }); // or { like: "a* quellan" }, or { name }
193
+ await rel.dancers.get(1001); // every print and school of one dancer
194
+ ```
195
+
196
+ NO NEW MATCHER. A print is read in the dancer's role by `@finbheara/domain` over
197
+ `@finbheara/names`, and a candidate's `nameLevel` is the finest of: `nn0` to `nn4` (equal at that
198
+ level of the names package: the print, the key, the folded key, the tokens in any order),
199
+ `initial` (one surname, one given name the other's made poorer: `givenAbbreviates`), `middle`
200
+ (a middle name one print omits: `nameRelation`). `age` is how the dancer's published birth window
201
+ meets the asked age (`Age.fitsIn`, `overlaps`): `fits`, `overlaps`, `contradicts`, `unknown`.
202
+ `school` is how the dancer's schools meet the asked one: `same` (the latest school as of `asOf`),
203
+ `moved` (an earlier one), `none`, `unresolved` (the print reached no school).
204
+
205
+ NO SILENT GUESS. Only a contradiction sets a candidate aside: a birth window that shares no birth
206
+ year with the asked age (listed in `excluded`). The school is evidence and order, never a reason
207
+ to drop anyone: candidates whose latest school is the asked one come first, then those that ever
208
+ danced for it, then the rest. A school row meets the asked school by its id, else by its print
209
+ (one `rel.schools` resolves to the asked ids), so a latest print the release
210
+ filed under no id is still `same`. `unique` is one candidate left after the age, never a best
211
+ guess promoted. Where the school singles out one candidate, `preferred` names it with the reason,
212
+ for a caller to weigh, and `arm` stays as counted. The `score` orders candidates and is no
213
+ probability.
214
+
215
+ A FEIS-LOCAL ID (`FEIS_LOCAL_ID_FLOOR`, 2^34, and up) is valid in its release only: a candidate and a result row
216
+ carrying one say `scope: "per-release"` (`dancer_scope` on a row), so a caller never stores one
217
+ across releases. Every answer carries the release tag.
218
+
219
+ ## Results and the majors
220
+
221
+ ```ts
222
+ await rel.results.query({ dancer_id: 1001 }); // a dancer's rows, oldest first
223
+ await rel.results.query({ feis: "<feis id>", year: 2025 });
224
+ await rel.results.query({ competition: "<competition>" });
225
+ await rel.results.query({ school_id: "<school id>", year: 2025, limit: 100 });
226
+ await rel.majors.events(); // every major and its years
227
+ await rel.majors.competitions({ event: "<event>", year: 2023 });
228
+ await rel.majors.results({ dancer_id: 1001 }); // a dancer's majors
229
+ ```
230
+
231
+ A row carries the prints and the ids that stand for them: the address, the competition's title
232
+ and age, the place, the name and school as printed with the dancer and school ids. The majors are
233
+ read from `majors.sqlite`, fetched on first use.
234
+
235
+ ## Teachers and adjudicators
236
+
237
+ ```ts
238
+ await rel.staff.teacher("<name>"); // or a TD number
239
+ await rel.staff.adjudicator("<name>");
240
+ ```
241
+
242
+ One person per TD number (else per judge id, else per name): the schools taught at (role,
243
+ credentials, years), the schools that list the TD number, the adjudicator register by year, and
244
+ the seats sat, per event and year, at majors and at feiseanna.
245
+ ## Competitions
246
+
247
+ ```ts
248
+ const c = await rel.comps.resolve({ feis: "fweb:ABC26", code: "610" });
249
+ c.arm; // "unique" | "ambiguous" | "none"
250
+ c.source; // "syllabus" | "prior-year" | "title"
251
+ c.kind; // "championship" | "grade" | "unrated"
252
+ c.level; // "OC" (FF, BG, AB, NV, PW, PC, OC), c.feis_level 700
253
+ c.age; // "U10", c.population "mixed", c.canonical "710OC", c.title, c.competition
254
+ c.from; // { feis_ids: ["fweb:ABC25"], year: 2025 } where an earlier year answered
255
+ c.check; // "unchecked", or what the caller's `title` says about the earlier comp
256
+ c.syllabus_source; // "results": what the syllabus the answer read is read from
257
+ await rel.comps.syllabus("fweb:ABC25"); // one feis-year's competitions, in printed order
258
+ const syl = await rel.comps.syllabusFor("Ardvarna Spring", 2026); // or a `feises.series` answer
259
+ syl.year; // the asked year where the release holds it, else its newest (syl.held, syl.why)
260
+ syl.source; // "results" (a syllabus document is a source of its own name, shipped by no release yet)
261
+ syl.entries; // the comps, syl.championships how many are championships, syl.note where none
262
+ ```
263
+
264
+ EVERY SYLLABUS SAYS WHAT IT IS READ FROM. Today that is the comps the release's result captures
265
+ list (`results`): a comp the feis held whose results the platform did not tabulate is not in it.
266
+ A feis whose platform tabulated its grades only has a syllabus with no championship, and
267
+ `syllabusFor` says so (`note`), as does a `none` answer of `resolve` on such a series.
268
+
269
+ A code is matched trimmed and case folded (`412a` finds `412A`). A code is the feis's own: FeisWeb, FeisWorx and a local QuickFeis number their competitions as
270
+ the host likes, so a code is answered from that feis's competitions in the release, never from
271
+ another platform's numbering. The sources, in order:
272
+
273
+ - `syllabus`: the feis-year is in the release, and one of its competitions prints the code
274
+ - `prior-year`: the feis-year is not (next year's, before its results): the newest earlier year
275
+ of the same series printing the code. Give the comp's `title` and `matchTitle` checks it
276
+ against the earlier comp (`check`), and with no title the answer says `unchecked`
277
+ - `title`: the code answers nothing and a `title` was given: the competitions of that syllabus
278
+ the title names, by band and population
279
+
280
+ A feis the release holds names itself, its year and its series. A series instance or brand id
281
+ names the series. A feis id the release does not hold is placed in a series by its host code
282
+ (`<token>:<stem><yy>` where the held codes of that stem carry their own year), and a stem two
283
+ series share answers `ambiguous`. A held feis-year never falls back to an earlier year, and two
284
+ competitions printing one code are never folded into one.
285
+
286
+ `kind` and `level` are read off the release's published rung (`feis_level`: 100 first feis to
287
+ 700 open championship): `championship` from 600, `grade` below, `unrated` where the release
288
+ publishes none (specials, sets, adult and team events). No level word is read from a title, so
289
+ a title alone names every comp of its band and population. `canonical` is a rendering of a
290
+ championship in the QuickFeis shape, never a key: an `Under N` band prints `N` (`609PC`,
291
+ `710OC`), and an `N & over` band prints `N + 1`, the slot after the oldest `Under` band, as
292
+ QuickFeis numbers its own and-over championships (`15 & over` prelim `616PC`, `18 & over` open
293
+ `719OC`). An and-over code is then the same string as an `Under N + 1` one, and `age` (`O18`)
294
+ tells them apart. An adult band, a range and a grade render none.
295
+
296
+ ## Registrations
297
+
298
+ ```ts
299
+ const rows = await rel.registrations.resolve([
300
+ { name: "Doe, J", school: "Ardvarna Academy", codes: ["610", "611"], feis: "fweb:ABC26" },
301
+ ]);
302
+ rows[0].comps; // one competition answer per code
303
+ rows[0].age; // { band: "U10", year: 2026, code: "610", from: "comp" }: the band the dancer is asked at
304
+ rows[0].dancer; // the dancer resolver's answer
305
+ ```
306
+
307
+ Each code is resolved as above. The dancer is asked at the YOUNGEST band the row's competitions
308
+ read (the lowest ceiling, then the highest floor): a dancer may dance up into an older band and
309
+ never down, so the youngest bounds the dancer. Every band a row read is listed (`bands`) where
310
+ there are several. Where no comp answers with a band (a syllabus read from results that lacks the
311
+ feis's championships), the band is read from the row's own titles (`titles`, by code) by the
312
+ domain package's title reader, the youngest again, and `age.from` says `title`. Nothing else
313
+ stands in for a band. The dancer resolver is the release's own (`rel.dancers`, above), or the
314
+ `dancerResolver` option. With `dancerResolver: null`, each row's dancer answers `unavailable`
315
+ and the competitions still resolve.
316
+
317
+ ## The command line
318
+
319
+ idq config repo <owner/name>
320
+ idq list [--remote]
321
+ idq fetch [--release <spec>] [--assets results,majors,<file>...]
322
+ idq pin <spec> | unpin | current
323
+ idq add <dir> --tag <tag>
324
+ idq index [--release <spec>] [<name>...] [--force]
325
+ idq verify [--release <spec>]
326
+ idq prune [--keep N] [--dry-run]
327
+ idq rm <tag> [--dry-run]
328
+ idq drop-indexes [<tag>] [--dry-run]
329
+ idq feis [--release <spec>] [--platform p] [--code c] [--id i] [--name glob] [--year y]
330
+ idq series [--release <spec>] <name|host code|feis id>
331
+ idq school [--release <spec>] <print|school_id> [--region <code>]
332
+ idq dancer [--release <spec>] <name> | --surname <s> | --like <glob> | --id <dancer_id>
333
+ [--order surname-first|given-first] [--school <print|id>]
334
+ [--yob <y> | --band <band> [--year <y>] | --comp <competition>] [--as-of <date>] [--rows]
335
+ idq results [--release <spec>] [--dancer <id>] [--feis <id>] [--comp <c>] [--school <s>] [--year <y>] [--limit <n>]
336
+ idq majors [--release <spec>] [--event <e>] [--year <y>] [--comp <c>] [--dancer <id>] [--rows]
337
+ idq teacher [--release <spec>] <name|TD number>
338
+ idq adjudicator [--release <spec>] <name|TD number>
339
+ idq comp [--release <spec>] <feis> <code> [--year y] [--title t]
340
+ idq syllabus [--release <spec>] <feis> [year]
341
+ idq syllabus [--release <spec>] --for <series> [year]
342
+ idq resolve comp [<feis> <code>] [--year y] [--title t]
343
+ idq resolve dancer [<name>] [--school s] [--comp <feis>/<code> | --yob y | --band b --year y]
344
+ idq resolve registrations
345
+
346
+ `dancer` prints the results of the one dancer where the answer is unique (every candidate's with
347
+ `--rows`). `--json` prints the call's answer as it is. `--cache` and `--repo` override the defaults.
348
+
349
+ `resolve` speaks JSON lines: one answer per line out, and with no positional question the
350
+ questions are JSON lines on stdin, one per line (`{feis, code, year?, title?}`,
351
+ `{name, school?, age?}`, `{name, school?, codes, feis, year?}`). A line that is no JSON object,
352
+ or no question of its kind, answers `{tag, line, error}` and the other lines still answer. The
353
+ exit is non-zero where any line erred. A dancer's age may be given as a comp
354
+ (`{"comp": "<feis>/<code>"}`, or `--comp` on the command line): `rel.registrations.dancer` reads
355
+ it through the comp's syllabus and asks the dancer resolver at that comp's band.
356
+
357
+ ## Licence
358
+
359
+ MIT
@@ -0,0 +1,75 @@
1
+ // @bun
2
+ var ARCHIVE_TAG2 = /^archive-(\d{4}\.\d{2}\.\d{2})(?:-(\d+)(?:\.([1-9]\d*))?)?$/;
3
+ function parseArchiveTag2(tag) {
4
+ const m = ARCHIVE_TAG2.exec(tag);
5
+ if (m === null)
6
+ return null;
7
+ const out = { date: m[1] };
8
+ if (m[2] !== undefined)
9
+ out.cut = Number(m[2]);
10
+ if (m[3] !== undefined)
11
+ out.fraction = Number(m[3]);
12
+ return out;
13
+ }
14
+ function renderArchiveTag2(t) {
15
+ if (t.cut === undefined) {
16
+ if ((t.fraction ?? 0) > 0)
17
+ throw new Error("archive tag: a fraction needs a cut number");
18
+ return `archive-${t.date}`;
19
+ }
20
+ return `archive-${t.date}-${t.cut}${(t.fraction ?? 0) > 0 ? `.${t.fraction}` : ""}`;
21
+ }
22
+ function cutOf2(inputRelease) {
23
+ const m = inputRelease === undefined ? null : /-(\d+)$/.exec(inputRelease);
24
+ return m === null ? undefined : Number(m[1]);
25
+ }
26
+ function compareTagsNewestFirst2(a, b) {
27
+ const pa = parseArchiveTag2(a);
28
+ const pb = parseArchiveTag2(b);
29
+ if (pa === null || pb === null)
30
+ return pa === null ? pb === null ? a < b ? 1 : -1 : 1 : -1;
31
+ if (pa.date !== pb.date)
32
+ return pa.date < pb.date ? 1 : -1;
33
+ const ca = pa.cut ?? -1;
34
+ const cb = pb.cut ?? -1;
35
+ if (ca !== cb)
36
+ return cb - ca;
37
+ const fa = pa.fraction ?? 0;
38
+ const fb = pb.fraction ?? 0;
39
+ if (fa !== fb)
40
+ return fb - fa;
41
+ return a < b ? 1 : a > b ? -1 : 0;
42
+ }
43
+ function parseReleaseSpec2(spec) {
44
+ if (typeof spec === "number")
45
+ return Number.isInteger(spec) && spec >= 0 ? { kind: "cut", cut: spec, fraction: 0 } : `release: ${spec} is not a cut number`;
46
+ const s = spec.trim();
47
+ if (s === "latest")
48
+ return { kind: "latest" };
49
+ if (s === "pinned")
50
+ return { kind: "pinned" };
51
+ const m = /^(\d+)(?:\.(\d+))?$/.exec(s);
52
+ if (m !== null)
53
+ return { kind: "cut", cut: Number(m[1]), fraction: Number(m[2] ?? 0) };
54
+ if (parseArchiveTag2(s) !== null)
55
+ return { kind: "tag", tag: s };
56
+ return `release: "${spec}" is not latest, pinned, a cut number (42, 42.1) or a release tag (archive-YYYY.MM.DD-N)`;
57
+ }
58
+ function pickTag2(tags, spec) {
59
+ const sorted = tags.filter((t) => parseArchiveTag2(t) !== null).sort(compareTagsNewestFirst2);
60
+ switch (spec.kind) {
61
+ case "latest":
62
+ return sorted.find((t) => parseArchiveTag2(t)?.cut !== undefined) ?? sorted[0] ?? null;
63
+ case "tag":
64
+ return tags.includes(spec.tag) ? spec.tag : null;
65
+ case "cut":
66
+ return sorted.find((t) => {
67
+ const p = parseArchiveTag2(t);
68
+ return p?.cut === spec.cut && (p.fraction ?? 0) === spec.fraction;
69
+ }) ?? null;
70
+ case "pinned":
71
+ return null;
72
+ }
73
+ }
74
+
75
+ export { ARCHIVE_TAG2, parseArchiveTag2, renderArchiveTag2, cutOf2, compareTagsNewestFirst2, parseReleaseSpec2, pickTag2 };
@@ -0,0 +1,57 @@
1
+ // @bun
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { dirname, join } from "path";
5
+ var set = (v) => v !== undefined && v.trim() !== "";
6
+ function expandHome2(p) {
7
+ return p === "~" || p.startsWith("~/") ? join(homedir(), p.slice(1)) : p;
8
+ }
9
+ function cacheRoot2(opts = {}) {
10
+ const env = opts.env ?? process.env;
11
+ if (set(opts.cache))
12
+ return expandHome2(opts.cache);
13
+ if (set(env.ARCHIVE_CLIENT_CACHE))
14
+ return expandHome2(env.ARCHIVE_CLIENT_CACHE);
15
+ return join(homedir(), ".cache", "archive-client");
16
+ }
17
+ var configPath2 = (root) => join(root, "config.json");
18
+ function readConfig2(root) {
19
+ const p = configPath2(root);
20
+ if (!existsSync(p))
21
+ return {};
22
+ try {
23
+ return JSON.parse(readFileSync(p, "utf8"));
24
+ } catch {
25
+ return {};
26
+ }
27
+ }
28
+ function writeJsonAtomic2(path, value) {
29
+ mkdirSync(dirname(path), { recursive: true });
30
+ const part = `${path}.${process.pid}.part`;
31
+ writeFileSync(part, `${JSON.stringify(value, null, "\t")}
32
+ `);
33
+ renameSync(part, path);
34
+ }
35
+ function writeConfig2(root, patch) {
36
+ const next = { ...readConfig2(root), ...patch };
37
+ writeJsonAtomic2(configPath2(root), next);
38
+ return next;
39
+ }
40
+ var REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
41
+ function repoOf2(opts = {}) {
42
+ const env = opts.env ?? process.env;
43
+ const r = [opts.repo, env.ARCHIVE_CLIENT_REPO, readConfig2(cacheRoot2(opts)).repo].find(set)?.trim();
44
+ if (r === undefined)
45
+ return null;
46
+ if (!REPO.test(r))
47
+ throw new Error(`archive-client: repo "${r}" is not owner/name`);
48
+ return r;
49
+ }
50
+ function requireRepo2(opts = {}) {
51
+ const r = repoOf2(opts);
52
+ if (r === null)
53
+ throw new Error("archive-client: no repository: pass `repo`, set ARCHIVE_CLIENT_REPO, or run `config repo <owner/name>`");
54
+ return r;
55
+ }
56
+
57
+ export { expandHome2, cacheRoot2, configPath2, readConfig2, writeJsonAtomic2, writeConfig2, repoOf2, requireRepo2 };