dsh-model-pool 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.
@@ -0,0 +1,595 @@
1
+ /**
2
+ * The Chinese statutory holiday calendar, fetched and cached.
3
+ *
4
+ * A worked calendar cannot be derived from a rule: the State Council shifts workdays
5
+ * around every year (调休), so the only honest sources are the published annual notices.
6
+ * This module reads a maintained machine-readable version of those notices, caches it on
7
+ * disk, and hands the router plain date sets.
8
+ *
9
+ * ## Why a data source and not a table
10
+ *
11
+ * A hard-coded table silently rots: the year it was not written for is simply wrong, and
12
+ * nothing reports that. Fetching means the table is refreshed by whoever maintains the
13
+ * dataset, and an unpublished year is an explicit, expected state rather than a bug.
14
+ *
15
+ * ## Offline is a first-class state
16
+ *
17
+ * Routing must never block on, or fail because of, the network. Every caller reads
18
+ * `holidays()` / `makeupWorkdays()` from memory; `refresh()` is the only thing that
19
+ * touches I/O, it is always cache-first, and it never throws.
20
+ *
21
+ * @module dsh-model-pool/lib/holiday-calendar
22
+ */
23
+
24
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
25
+ import { join } from 'node:path';
26
+
27
+ /**
28
+ * Source id to the URLs tried in order.
29
+ *
30
+ * Three independent mirrors, because any one of them can be slow or blocked depending on
31
+ * the network: jsDelivr's main CDN, jsDelivr's Fastly endpoint, then GitHub itself. The
32
+ * first that answers wins.
33
+ */
34
+ export const DEFAULT_SOURCE_URLS = {
35
+ 'holiday-cn': [
36
+ 'https://cdn.jsdelivr.net/gh/NateScarlet/holiday-cn@master/{year}.json',
37
+ 'https://fastly.jsdelivr.net/gh/NateScarlet/holiday-cn@master/{year}.json',
38
+ 'https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/{year}.json',
39
+ ],
40
+ };
41
+
42
+ /** Cache file name inside the data directory. */
43
+ const CACHE_FILE = 'calendar.json';
44
+
45
+ /** How many years of history to load alongside the current one. */
46
+ const HISTORY_YEARS = 1;
47
+
48
+ /** Network budget for one year's document. */
49
+ const FETCH_TIMEOUT_MS = 8000;
50
+
51
+ /** Shape version of the on-disk cache. */
52
+ const CACHE_VERSION = 1;
53
+
54
+ /** A `YYYY-MM-DD` calendar date, validated the same way `lib/peak.js` validates one. */
55
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
56
+
57
+ /**
58
+ * Whether a string is a real calendar date in `YYYY-MM-DD` form.
59
+ * @param value - candidate value.
60
+ * @returns true for a date such as `2026-02-17`.
61
+ */
62
+ function isDate(value) {
63
+ if (typeof value !== 'string' || !DATE_RE.test(value)) return false;
64
+ const year = Number(value.slice(0, 4));
65
+ const month = Number(value.slice(5, 7));
66
+ const day = Number(value.slice(8, 10));
67
+ if (month < 1 || month > 12 || day < 1 || day > 31) return false;
68
+ const probe = new Date(Date.UTC(year, month - 1, day));
69
+ return probe.getUTCFullYear() === year && probe.getUTCMonth() === month - 1 && probe.getUTCDate() === day;
70
+ }
71
+
72
+ /**
73
+ * Parse one year's source document.
74
+ *
75
+ * The document is untrusted: it arrives over the network. Every entry is validated and
76
+ * unusable ones are dropped, so a corrupted or truncated response degrades to "fewer
77
+ * known holidays" instead of a wrong answer or an exception in the router.
78
+ *
79
+ * @param raw - the parsed JSON body, or a JSON string.
80
+ * @returns `{ year, papers, days }` with `days` holding only well-formed entries.
81
+ * @throws when the document is not a usable calendar at all.
82
+ */
83
+ export function parseSourceDocument(raw) {
84
+ const doc = typeof raw === 'string' ? JSON.parse(raw) : raw;
85
+ if (doc === null || typeof doc !== 'object') throw new Error('holiday calendar: not an object');
86
+ if (!Array.isArray(doc.days)) throw new Error('holiday calendar: no "days" array');
87
+ const year = Number.isFinite(doc.year) ? Math.trunc(doc.year) : undefined;
88
+ const days = [];
89
+ for (const entry of doc.days) {
90
+ if (entry === null || typeof entry !== 'object') continue;
91
+ if (!isDate(entry.date)) continue;
92
+ if (typeof entry.isOffDay !== 'boolean') continue;
93
+ const day = { date: entry.date, isOffDay: entry.isOffDay };
94
+ // The holiday name is display-only, so a missing one is not a defect.
95
+ if (typeof entry.name === 'string' && entry.name !== '') day.name = entry.name;
96
+ days.push(day);
97
+ }
98
+ const papers = (Array.isArray(doc.papers) ? doc.papers : [])
99
+ .filter((paper) => typeof paper === 'string' && paper !== '')
100
+ .map((paper) => paper.trim());
101
+ return { ...year === undefined ? {} : { year }, papers, days };
102
+ }
103
+
104
+ /**
105
+ * Split parsed days into the two sets the peak policy needs.
106
+ *
107
+ * `isOffDay: true` is a statutory holiday (法定节假日); `isOffDay: false` is a 调休上班日 —
108
+ * a weekend the State Council opened for work. Keeping them separate is what lets the
109
+ * policy treat "closed" and "opened a Saturday" as different facts.
110
+ *
111
+ * @param days - parsed days.
112
+ * @returns `{ holidays, makeupWorkdays }`, each sorted and de-duplicated.
113
+ */
114
+ export function toCalendar(days) {
115
+ const holidays = new Set();
116
+ const makeupWorkdays = new Set();
117
+ for (const day of Array.isArray(days) ? days : []) {
118
+ if (day === null || typeof day !== 'object' || !isDate(day.date)) continue;
119
+ if (day.isOffDay === true) holidays.add(day.date);
120
+ else if (day.isOffDay === false) makeupWorkdays.add(day.date);
121
+ }
122
+ return {
123
+ holidays: [...holidays].sort(),
124
+ makeupWorkdays: [...makeupWorkdays].sort(),
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Union several calendars.
130
+ * @param calendars - calendars to merge.
131
+ * @returns one calendar holding every date seen, sorted.
132
+ */
133
+ export function mergeCalendars(...calendars) {
134
+ const holidays = new Set();
135
+ const makeup = new Set();
136
+ for (const calendar of calendars.flat()) {
137
+ if (calendar === null || typeof calendar !== 'object') continue;
138
+ for (const date of Array.isArray(calendar.holidays) ? calendar.holidays : []) {
139
+ if (isDate(date)) holidays.add(date);
140
+ }
141
+ for (const date of Array.isArray(calendar.makeupWorkdays) ? calendar.makeupWorkdays : []) {
142
+ if (isDate(date)) makeup.add(date);
143
+ }
144
+ }
145
+ return {
146
+ holidays: [...holidays].sort(),
147
+ makeupWorkdays: [...makeup].sort(),
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Layer manual corrections on top of a fetched calendar.
153
+ *
154
+ * An override is a date an operator decided differently about, so it wins outright: it is
155
+ * added to the set it names and removed from the other, which keeps the two lists
156
+ * consistent after a correction. Without this, moving a date from `holidays` to
157
+ * `makeupWorkdays` would leave it in both and the router's precedence rule would quietly
158
+ * override the operator.
159
+ *
160
+ * @param calendar - the fetched calendar.
161
+ * @param override - `{ holidays?, makeupWorkdays? }` manual dates.
162
+ * @returns the corrected calendar.
163
+ */
164
+ export function mergeOverride(calendar, override = {}) {
165
+ const holidays = new Set(Array.isArray(calendar?.holidays) ? calendar.holidays : []);
166
+ const makeup = new Set(Array.isArray(calendar?.makeupWorkdays) ? calendar.makeupWorkdays : []);
167
+ for (const date of Array.isArray(override?.holidays) ? override.holidays : []) {
168
+ if (!isDate(date)) continue;
169
+ holidays.add(date);
170
+ makeup.delete(date);
171
+ }
172
+ for (const date of Array.isArray(override?.makeupWorkdays) ? override.makeupWorkdays : []) {
173
+ if (!isDate(date)) continue;
174
+ makeup.add(date);
175
+ holidays.delete(date);
176
+ }
177
+ return { holidays: [...holidays].sort(), makeupWorkdays: [...makeup].sort() };
178
+ }
179
+
180
+ /**
181
+ * Expand a URL template for one year.
182
+ * @param template - a URL containing `{year}`, or empty to use the source default.
183
+ * @param year - calendar year.
184
+ * @param source - source id, used when `template` is empty.
185
+ * @returns the first URL to try, or undefined when no template is known.
186
+ */
187
+ export function buildUrl(template, year, source = 'holiday-cn') {
188
+ const chosen = typeof template === 'string' && template.trim() !== ''
189
+ ? template.trim()
190
+ : DEFAULT_SOURCE_URLS[source]?.[0];
191
+ if (typeof chosen !== 'string') return undefined;
192
+ return chosen.replaceAll('{year}', String(year));
193
+ }
194
+
195
+ /**
196
+ * Every URL to try for one year, in order.
197
+ * @param template - configured template; empty uses the source defaults.
198
+ * @param year - calendar year.
199
+ * @param source - source id.
200
+ * @returns the candidate URLs.
201
+ */
202
+ export function sourceUrls(template, year, source = 'holiday-cn') {
203
+ const own = typeof template === 'string' && template.trim() !== '' ? [template.trim()] : [];
204
+ const candidates = own.length > 0 ? own : DEFAULT_SOURCE_URLS[source] ?? [];
205
+ return candidates.map((url) => url.replaceAll('{year}', String(year)));
206
+ }
207
+
208
+ /**
209
+ * Whether a cached calendar should be re-fetched.
210
+ *
211
+ * Two independent reasons: the cache is simply old (schedule drift), or a year the policy
212
+ * can currently reach is missing because it had not been published yet when the cache was
213
+ * written. The second is what makes a November notice land without a restart.
214
+ *
215
+ * @param meta - cache metadata (`fetchedAt`, `years`).
216
+ * @param now - clock reading.
217
+ * @param options - `{ cacheDays, years }`.
218
+ * @returns true when a refresh is warranted.
219
+ */
220
+ export function needsRefresh(meta, now, options = {}) {
221
+ const fetchedAt = Number.isFinite(meta?.fetchedAt) ? meta.fetchedAt : undefined;
222
+ if (fetchedAt === undefined) return true;
223
+ const cacheDays = Number.isFinite(options.cacheDays) ? options.cacheDays : 30;
224
+ if (now - fetchedAt >= cacheDays * 86_400_000) return true;
225
+ const loaded = new Set(Array.isArray(meta?.years) ? meta.years : []);
226
+ return (Array.isArray(options.years) ? options.years : []).some((year) => !loaded.has(year));
227
+ }
228
+
229
+ /**
230
+ * The years the current policy can reach: this year, next year (their notice may already
231
+ * be out) and enough history to cover a session that started just before New Year.
232
+ *
233
+ * Next year is not optional. The dataset files a date under the *notice's* title year, not
234
+ * the calendar year, so a December date can belong to the following year's document — the
235
+ * upstream dataset says to check both. Loading one year early also means the next year's
236
+ * notice lands on its own once it is published in November.
237
+ *
238
+ * @param now - clock reading, epoch milliseconds.
239
+ * @returns ascending years.
240
+ */
241
+ export function yearsAround(now) {
242
+ const year = new Date(now).getUTCFullYear();
243
+ const years = [];
244
+ for (let offset = HISTORY_YEARS; offset >= 0; offset -= 1) years.push(year - offset);
245
+ years.push(year + 1);
246
+ return years;
247
+ }
248
+
249
+ /**
250
+ * One process-lifetime holiday calendar.
251
+ *
252
+ * Holds the merged date sets in memory so a routing decision is a set lookup, and writes
253
+ * its state to a single JSON file so a restart does not re-download.
254
+ */
255
+ export class HolidayCalendar {
256
+ /**
257
+ * @param {object} options - calendar dependencies.
258
+ * @param {string} options.dir - data directory; the cache lives at `<dir>/calendar.json`.
259
+ * @param {string} [options.source] - source id, default `holiday-cn`.
260
+ * @param {string} [options.urlTemplate] - custom URL template with `{year}`.
261
+ * @param {object} [options.logger] - host logger.
262
+ * @param {Function} [options.fetchImpl] - fetch implementation, injectable for tests.
263
+ * @param {() => number} [options.now] - clock, injectable for tests.
264
+ */
265
+ constructor({ dir, source = 'holiday-cn', urlTemplate = '', logger, fetchImpl, now } = {}) {
266
+ this.dir = dir;
267
+ this.source = source;
268
+ this.urlTemplate = urlTemplate;
269
+ this.logger = logger;
270
+ this.fetchImpl = typeof fetchImpl === 'function' ? fetchImpl : globalThis.fetch;
271
+ this.now = typeof now === 'function' ? now : () => Date.now();
272
+ /** Per-year calendars as fetched, keyed by year. */
273
+ this.byYear = new Map();
274
+ /** Per-year source metadata (`papers`). */
275
+ this.papers = new Map();
276
+ /** Years the source answered for but has not published a notice for yet. */
277
+ this.pending = new Set();
278
+ /** Years the source answered for, but has not published a notice for yet. */
279
+ this.pending = new Set();
280
+ this.fetchedAt = undefined;
281
+ this.lastError = undefined;
282
+ /** Serializes refreshes so concurrent calls share one request. */
283
+ this.refreshing = undefined;
284
+ }
285
+
286
+ /**
287
+ * Load the cache from disk once. Safe to call repeatedly.
288
+ * @returns {Promise<boolean>} true when a usable cache was read.
289
+ */
290
+ async load() {
291
+ if (this.loaded === true) return this.ready();
292
+ this.loaded = true;
293
+ try {
294
+ const text = await readFile(join(this.dir, CACHE_FILE), 'utf8');
295
+ const cache = JSON.parse(text);
296
+ if (cache?.version !== CACHE_VERSION || cache.source !== this.source) return false;
297
+ if (!Array.isArray(cache.years)) return false;
298
+ for (const entry of cache.years) {
299
+ if (entry === null || typeof entry !== 'object') continue;
300
+ const year = Number(entry.year);
301
+ if (!Number.isFinite(year)) continue;
302
+ const calendar = mergeCalendars({
303
+ holidays: Array.isArray(entry.holidays) ? entry.holidays : [],
304
+ makeupWorkdays: Array.isArray(entry.makeupWorkdays) ? entry.makeupWorkdays : [],
305
+ });
306
+ this.byYear.set(year, calendar);
307
+ this.papers.set(year, Array.isArray(entry.papers) ? entry.papers : []);
308
+ }
309
+ if (Number.isFinite(cache.fetchedAt)) this.fetchedAt = cache.fetchedAt;
310
+ return this.ready();
311
+ } catch {
312
+ // No cache, or an unreadable one: `ready()` stays false and the first refresh
313
+ // simply has nothing to fall back on.
314
+ return false;
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Write the current state to disk.
320
+ * @returns {Promise<void>} settles when the write is attempted.
321
+ */
322
+ async save() {
323
+ const payload = {
324
+ version: CACHE_VERSION,
325
+ source: this.source,
326
+ fetchedAt: this.fetchedAt,
327
+ years: [...this.byYear].map(([year, calendar]) => ({
328
+ year,
329
+ ...calendar,
330
+ papers: this.papers.get(year) ?? [],
331
+ })),
332
+ };
333
+ try {
334
+ await mkdir(this.dir, { recursive: true });
335
+ await writeFile(join(this.dir, CACHE_FILE), JSON.stringify(payload, null, ' '), 'utf8');
336
+ } catch (error) {
337
+ // A read-only data directory must not break routing; the calendar simply
338
+ // becomes memory-only for this process generation.
339
+ this.logger?.warn?.('model-pool: could not write the holiday cache: %s', error?.message ?? String(error));
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Point the calendar at a different source.
345
+ *
346
+ * A changed source or template invalidates the loaded years: they came from the old
347
+ * source, and silently mixing two sources would produce a calendar nobody can reason
348
+ * about. The cache stays on disk until the next refresh overwrites it.
349
+ *
350
+ * @param {object} options - `{ source, urlTemplate }`.
351
+ * @returns {boolean} true when the source actually changed.
352
+ */
353
+ reconfigure({ source, urlTemplate } = {}) {
354
+ const nextSource = typeof source === 'string' && source !== '' ? source : this.source;
355
+ const nextTemplate = typeof urlTemplate === 'string' ? urlTemplate : this.urlTemplate;
356
+ if (nextSource === this.source && nextTemplate === this.urlTemplate) return false;
357
+ this.source = nextSource;
358
+ this.urlTemplate = nextTemplate;
359
+ this.byYear.clear();
360
+ this.papers.clear();
361
+ this.pending.clear();
362
+ this.fetchedAt = undefined;
363
+ this.lastError = undefined;
364
+ return true;
365
+ }
366
+
367
+ /**
368
+ * The newest year loaded.
369
+ * @returns {number|undefined} that year, or undefined before the first load.
370
+ */
371
+ #newestYear() {
372
+ let newest;
373
+ for (const year of this.byYear.keys()) {
374
+ if (newest === undefined || year > newest) newest = year;
375
+ }
376
+ return newest;
377
+ }
378
+
379
+ /** @returns {boolean} true when at least one year is known. */
380
+ ready() {
381
+ return this.byYear.size > 0;
382
+ }
383
+
384
+ /** The merged calendar across every loaded year. */
385
+ calendar() {
386
+ return mergeCalendars(...this.byYear.values());
387
+ }
388
+
389
+ /** @returns {Set<string>} statutory holidays. */
390
+ holidays() {
391
+ return new Set(this.calendar().holidays);
392
+ }
393
+
394
+ /** @returns {Set<string>} 调休上班日. */
395
+ makeupWorkdays() {
396
+ return new Set(this.calendar().makeupWorkdays);
397
+ }
398
+
399
+ /** The official notice URLs the loaded data was generated from. */
400
+ paperList() {
401
+ const papers = new Set();
402
+ for (const list of this.papers.values()) {
403
+ for (const paper of list) papers.add(paper);
404
+ }
405
+ return [...papers].sort();
406
+ }
407
+
408
+ /**
409
+ * A JSON-friendly view for the settings API.
410
+ *
411
+ * Two different questions live in this payload, and they are deliberately kept apart.
412
+ * The whole-set fields (`years`, `holidayCount`, `makeupCount`) describe everything
413
+ * loaded, because routing consumes all of it: the 16-day `nextChangeAt` lookahead
414
+ * crosses New Year, so a session on 31 December still needs next year's notice. The
415
+ * `current*` triple answers the narrower question a reader actually has — "is today's
416
+ * calendar right?" — and Settings shows only that.
417
+ *
418
+ * Pure read: nothing here is mutated, and the counts are derived from the year already
419
+ * in memory, so a `/state` poll stays a couple of array scans.
420
+ *
421
+ * @param {object} [options] - `{ cacheDays, year }`.
422
+ * @param {number} [options.year] - year to report as current; defaults to the UTC
423
+ * calendar year of `now()`, matching `yearsAround` so the module has one convention.
424
+ * @returns the status snapshot.
425
+ */
426
+ status(options = {}) {
427
+ const calendar = this.calendar();
428
+ const fetchedAt = this.fetchedAt;
429
+ const currentYear = Number.isFinite(options.year)
430
+ ? Math.trunc(options.year)
431
+ : new Date(this.now()).getUTCFullYear();
432
+ const current = this.byYear.get(currentYear);
433
+ // A year that is not loaded reports zero rather than borrowing a neighbour's
434
+ // figures: "nothing known for this year" is the honest answer, and the card says
435
+ // so. Silently showing another year's counts would misreport today's policy.
436
+ const prefix = `${currentYear}-`;
437
+ const currentHolidays = (current?.holidays ?? []).filter((date) => date.startsWith(prefix));
438
+ const currentMakeup = (current?.makeupWorkdays ?? []).filter((date) => date.startsWith(prefix));
439
+ return {
440
+ years: [...this.byYear.keys()].sort((left, right) => left - right),
441
+ // Years the source knows about but has no notice for yet: published in November,
442
+ // so Settings can say "not published" instead of implying the data is complete.
443
+ pendingYears: [...this.pending].sort((left, right) => left - right),
444
+ fetchedAt: fetchedAt ?? null,
445
+ source: this.source,
446
+ stale: fetchedAt === undefined
447
+ ? true
448
+ : this.now() - fetchedAt >= (Number.isFinite(options.cacheDays) ? options.cacheDays : 30) * 86_400_000,
449
+ lastError: this.lastError ?? null,
450
+ holidayCount: calendar.holidays.length,
451
+ makeupCount: calendar.makeupWorkdays.length,
452
+ papers: this.paperList(),
453
+ // One year only — the one the caller named. See the note above.
454
+ currentYear,
455
+ currentHolidayCount: currentHolidays.length,
456
+ currentMakeupCount: currentMakeup.length,
457
+ };
458
+ }
459
+
460
+ /**
461
+ * Fetch one year's document.
462
+ * @param {number} year - calendar year.
463
+ * @returns {Promise<object|null>} the calendar for that year, or null when unavailable.
464
+ */
465
+ async #fetchYear(year) {
466
+ const urls = sourceUrls(this.urlTemplate, year, this.source);
467
+ for (const url of urls) {
468
+ try {
469
+ if (typeof this.fetchImpl !== 'function') throw new Error('no fetch implementation');
470
+ const controller = typeof AbortController === 'function' ? new AbortController() : undefined;
471
+ const timer = controller === undefined ? undefined : setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
472
+ let response;
473
+ try {
474
+ response = await this.fetchImpl(url, {
475
+ headers: { accept: 'application/json' },
476
+ ...controller === undefined ? {} : { signal: controller.signal },
477
+ });
478
+ } finally {
479
+ if (timer !== undefined) clearTimeout(timer);
480
+ }
481
+ // A year whose notice has not been published yet answers 404, or 200 with an
482
+ // empty `days` array (the dataset pre-creates the file once the year starts).
483
+ // Both mean "not published", which is a state of the world, not a failure:
484
+ // the callers must not report it as an error, and must keep asking for it.
485
+ if (response?.status === 404) return { year, published: false, papers: [] };
486
+ if (response?.ok !== true) throw new Error(`HTTP ${response?.status ?? '?'}`);
487
+ const text = await response.text();
488
+ const parsed = parseSourceDocument(text);
489
+ const calendar = toCalendar(parsed.days);
490
+ const published = calendar.holidays.length > 0 || calendar.makeupWorkdays.length > 0;
491
+ return { year, published, ...calendar, papers: parsed.papers };
492
+ } catch (error) {
493
+ this.logger?.debug?.(
494
+ 'model-pool: holiday calendar %s from %s failed: %s',
495
+ year,
496
+ url,
497
+ error?.message ?? String(error),
498
+ );
499
+ }
500
+ }
501
+ return null;
502
+ }
503
+
504
+ /**
505
+ * Refresh from the source, cache first.
506
+ *
507
+ * Concurrent callers share one run. A refresh that finds a usable cache does no I/O at
508
+ * all unless `force` is set or `needsRefresh` says otherwise.
509
+ *
510
+ * @param {object} [options] - `{ force, cacheDays, years }`.
511
+ * @returns {Promise<object>} the status after the attempt.
512
+ */
513
+ async refresh(options = {}) {
514
+ if (this.refreshing !== undefined) return this.refreshing;
515
+ this.refreshing = (async () => {
516
+ try {
517
+ await this.load();
518
+ const now = this.now();
519
+ const years = Array.isArray(options.years) && options.years.length > 0 ? options.years : yearsAround(now);
520
+ const meta = { fetchedAt: this.fetchedAt, years: [...this.byYear.keys()] };
521
+ const wanted = options.force === true
522
+ || !this.ready()
523
+ || needsRefresh(meta, now, { cacheDays: options.cacheDays, years });
524
+ if (!wanted) return this.status(options);
525
+
526
+ // Two different reasons to be here. A cache that has aged out is re-read
527
+ // wholesale. A fresh cache that is merely missing a year (its notice is not out
528
+ // yet) only re-asks for that year, so the steady state before a November
529
+ // publication costs one request per sync, not one per known year.
530
+ const cacheDays = Number.isFinite(options.cacheDays) ? options.cacheDays : 30;
531
+ const aged = this.fetchedAt === undefined || now - this.fetchedAt >= cacheDays * 86_400_000;
532
+ const wantedYears = options.force === true || aged || !this.ready()
533
+ ? years
534
+ : years.filter((year) => !this.byYear.has(year));
535
+ if (wantedYears.length === 0) return this.status(options);
536
+
537
+ let failures = 0;
538
+ let changed = false;
539
+ for (const year of wantedYears) {
540
+ // A past year is settled once it is known: its notice has been published and
541
+ // the dataset no longer moves. Only the newest loaded year and any year still
542
+ // missing are worth asking for, which keeps a monthly refresh to one or two
543
+ // requests instead of one per loaded year.
544
+ const newest = this.#newestYear();
545
+ const settled = this.byYear.has(year) && newest !== undefined && year < newest;
546
+ if (options.force !== true && settled) continue;
547
+ const calendar = await this.#fetchYear(year);
548
+ if (calendar === null) {
549
+ // A transport or parse failure. Only worth reporting when we have nothing
550
+ // at all for that year; a cached year keeps serving.
551
+ if (!this.byYear.has(year)) failures += 1;
552
+ continue;
553
+ }
554
+ if (calendar.published !== true) {
555
+ // The notice is not out yet. Recording the year would make `needsRefresh`
556
+ // believe it is settled, and the November publication would then wait for
557
+ // the cache to age out instead of landing on the next sync. So a pending
558
+ // year is deliberately left out of `byYear` and named separately.
559
+ this.pending.add(year);
560
+ continue;
561
+ }
562
+ this.byYear.set(year, {
563
+ holidays: calendar.holidays,
564
+ makeupWorkdays: calendar.makeupWorkdays,
565
+ });
566
+ this.papers.set(year, calendar.papers);
567
+ // It is published now, so it is no longer pending.
568
+ this.pending.delete(year);
569
+ changed = true;
570
+ }
571
+ if (changed) {
572
+ this.fetchedAt = now;
573
+ this.lastError = undefined;
574
+ await this.save();
575
+ } else if (!this.ready()) {
576
+ // Nothing loaded and nothing fetched: say so once, and keep routing on
577
+ // the weekday rule rather than failing.
578
+ this.lastError = `could not load the holiday calendar (source: ${this.source})`;
579
+ this.logger?.warn?.('model-pool: %s', this.lastError);
580
+ } else if (failures > 0) {
581
+ this.lastError = `${failures} year(s) unavailable; serving the cached calendar`;
582
+ }
583
+ return this.status(options);
584
+ } catch (error) {
585
+ // Belt and braces: routing must survive any surprise here.
586
+ this.lastError = error?.message ?? String(error);
587
+ this.logger?.warn?.('model-pool: holiday calendar refresh failed: %s', this.lastError);
588
+ return this.status(options);
589
+ } finally {
590
+ this.refreshing = undefined;
591
+ }
592
+ })();
593
+ return this.refreshing;
594
+ }
595
+ }