@motionstudies/data 0.1.0-alpha.3 → 0.1.0-alpha.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +247 -2
  2. package/adsb-heatmap.d.mts +56 -0
  3. package/adsb-heatmap.mjs +223 -0
  4. package/air-endpoints.d.mts +7 -0
  5. package/air-endpoints.mjs +148 -0
  6. package/feed-expectations.d.mts +3 -0
  7. package/feed-expectations.mjs +33 -0
  8. package/feed-incidents.d.mts +44 -0
  9. package/feed-incidents.mjs +148 -0
  10. package/feed-observability.d.mts +68 -0
  11. package/feed-observability.mjs +154 -0
  12. package/feed-observer.d.mts +30 -0
  13. package/feed-observer.mjs +192 -0
  14. package/network-chunk-cli.d.mts +1 -0
  15. package/network-chunk-cli.mjs +65 -0
  16. package/observation-windows.d.mts +38 -0
  17. package/observation-windows.mjs +76 -0
  18. package/operational-files.mjs +21 -0
  19. package/package.json +85 -2
  20. package/rail-geometry.d.mts +7 -0
  21. package/rail-geometry.mjs +108 -0
  22. package/rail-journeys.d.mts +14 -0
  23. package/rail-journeys.mjs +118 -0
  24. package/rail-public-calls.d.mts +4 -0
  25. package/rail-public-calls.mjs +48 -0
  26. package/rail-routing.d.mts +16 -0
  27. package/rail-routing.mjs +119 -0
  28. package/rail-wtt.d.mts +12 -0
  29. package/rail-wtt.mjs +135 -0
  30. package/rail-xml.mjs +12 -0
  31. package/recorder-evidence.d.mts +14 -0
  32. package/recorder-evidence.mjs +136 -0
  33. package/recorder-observability.d.mts +3 -0
  34. package/recorder-observability.mjs +106 -0
  35. package/release-files.d.mts +14 -0
  36. package/release-files.mjs +52 -0
  37. package/release.d.mts +10 -0
  38. package/release.mjs +62 -0
  39. package/source-store.d.mts +15 -0
  40. package/source-store.mjs +160 -0
  41. package/uk-service-day.d.mts +7 -0
  42. package/uk-service-day.mjs +49 -0
  43. package/webtris.d.mts +14 -0
  44. package/webtris.mjs +67 -0
@@ -0,0 +1,136 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { isAbsolute, dirname, join } from 'node:path'
3
+ import { readOperationalFile } from './operational-files.mjs'
4
+ import { readFeedRegistry, readFeedHealth, readOperationalTime, summarizeFeedStages } from './feed-observability.mjs'
5
+ import { studyDay } from './uk-service-day.mjs'
6
+
7
+ const hash = bytes => createHash('sha256').update(bytes).digest('hex')
8
+ const digest = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)
9
+ const count = value => Number.isSafeInteger(value) && value >= 0
10
+ function keys(value, allowed) {
11
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(k => !allowed.includes(k))) throw new Error('Invalid evidence plan')
12
+ }
13
+ function path(value) { if (typeof value !== 'string' || !isAbsolute(value) || value.length > 4096) throw new Error('Evidence paths must be absolute'); return value }
14
+ function date(value) { readOperationalTime(`${value}T00:00:00Z`); return value }
15
+ function readPlan(value, registry, producerId) {
16
+ keys(value, ['schemaVersion', 'kind', 'producerId', 'validUntil', 'feeds'])
17
+ if (value.schemaVersion !== 1 || value.kind !== 'recorder-evidence-plan' || value.producerId !== producerId || !Array.isArray(value.feeds) || value.feeds.length > 16) throw new Error('Invalid evidence plan')
18
+ const validUntil = readOperationalTime(value.validUntil)
19
+ const feeds = value.feeds.map(f => {
20
+ keys(f, ['feedId', 'archive', 'publication'])
21
+ const entry = registry.feeds.find(e => e.id === f.feedId && e.producerId === producerId)
22
+ if (!entry) throw new Error('Unregistered evidence feed')
23
+ const output = { feedId: f.feedId }
24
+ for (const id of ['archive', 'publication']) if (f[id]) {
25
+ if (!entry.stages.some(s => s.id === id)) throw new Error('Unregistered evidence stage')
26
+ if (id === 'archive') {
27
+ keys(f[id], ['journalPath', 'closeoutPath', 'expectedEnd', 'deadlineAt'])
28
+ output.archive = { journalPath: path(f[id].journalPath), closeoutPath: path(f[id].closeoutPath), expectedEnd: readOperationalTime(f[id].expectedEnd), deadlineAt: readOperationalTime(f[id].deadlineAt) }
29
+ } else {
30
+ keys(f[id], ['pointerPath', 'operator', 'expectedThrough', 'deadlineAt'])
31
+ if (typeof f[id].operator !== 'string' || !f[id].operator.trim() || f[id].operator.length > 100) throw new Error('Invalid operator')
32
+ output.publication = { pointerPath: path(f[id].pointerPath), operator: f[id].operator, expectedThrough: date(f[id].expectedThrough), deadlineAt: readOperationalTime(f[id].deadlineAt) }
33
+ }
34
+ const end = id === 'archive' ? output.archive.expectedEnd : studyDay(output.publication.expectedThrough).endUtc
35
+ if (Date.parse(output[id].deadlineAt) < Date.parse(end)) throw new Error('Evidence deadline precedes expected period end')
36
+ if (Date.parse(output[id].deadlineAt) > Date.parse(validUntil)) throw new Error('Evidence deadline exceeds plan validity')
37
+ }
38
+ return output
39
+ })
40
+ if (new Set(feeds.map(f => f.feedId)).size !== feeds.length) throw new Error('Duplicate evidence feed')
41
+ return { feeds, validUntil }
42
+ }
43
+ const result = (stageId, state, reason, timestamps = {}, metrics = {}) => ({ stageId, state, reasons: reason ? [reason] : [], timestamps, metrics })
44
+ const pending = (id, deadline, now, reason) => result(id, now > Date.parse(deadline) ? 'degraded' : 'waiting', `${reason}-${now > Date.parse(deadline) ? 'overdue' : 'pending'}`)
45
+
46
+ /** Read explicit local evidence targets; never infer paths from a status report or fetch R2. */
47
+ export async function recorderEvidenceHealth(registryValue, reportValue, planValue) {
48
+ const registry = readFeedRegistry(registryValue), report = readFeedHealth(reportValue, registry)
49
+ const plan = readPlan(planValue, registry, report.producerId), now = Date.parse(report.observedAt)
50
+ // A national bus day's journal is about 140 MiB; smaller feeds are a few MiB.
51
+ let remaining = 512 * 1024 * 1024
52
+ const read = async (path, limit) => {
53
+ if (remaining <= 0) throw new Error('Evidence byte budget exceeded')
54
+ const bytes = await readOperationalFile(path, Math.min(limit, remaining))
55
+ remaining -= bytes.length
56
+ return { bytes, json: JSON.parse(bytes) }
57
+ }
58
+ for (const f of plan.feeds) {
59
+ const feed = report.feeds.find(e => e.feedId === f.feedId)
60
+ if (report.telemetry.state !== 'current' || feed.configuredState !== 'active') continue
61
+ const entry = registry.feeds.find(e => e.id === f.feedId)
62
+ for (const stageId of ['archive', 'publication']) {
63
+ if (!f[stageId]) continue
64
+ let evidence
65
+ try {
66
+ evidence = now > Date.parse(plan.validUntil) ? result(stageId, 'unknown', 'evidence-plan-expired') : stageId === 'archive' ? await archive(f.archive, entry, read, now, registry.clockSkewSeconds) : await publication(f.publication, read, now, registry.clockSkewSeconds)
67
+ } catch (error) {
68
+ evidence = error.code === 'ENOENT' ? pending(stageId, f[stageId].deadlineAt, now, `${stageId}-evidence`) : result(stageId, 'unknown', `${stageId}-evidence-invalid`)
69
+ }
70
+ feed.stages = feed.stages.map(s => s.stageId === stageId ? evidence : s)
71
+ }
72
+ feed.state = summarizeFeedStages(feed.stages)
73
+ }
74
+ return readFeedHealth(report, registry)
75
+ }
76
+
77
+ async function archive(plan, entry, read, now, skew) {
78
+ const { bytes, json: journal } = await read(plan.journalPath, 256 * 1024 * 1024)
79
+ if (!journal.id || journal.config?.source !== entry.sourceId || !['complete', 'stopped'].includes(journal.status) || !Array.isArray(journal.attempts)) throw new Error('Invalid closed journal')
80
+ const end = readOperationalTime(journal.plannedEnd), start = readOperationalTime(journal.startedAt)
81
+ if (end !== plan.expectedEnd || Date.parse(end) < Date.parse(start) || Date.parse(end) > now || Date.parse(plan.deadlineAt) < Date.parse(end)) throw new Error('Invalid archive period/deadline')
82
+ const dates = [...new Set(journal.attempts.filter(a => a.status === 'captured').map(a => readOperationalTime(a.receivedAt).slice(0, 10)))].sort()
83
+ if (!dates.length || dates.length > 7) throw new Error('Archive has no bounded captured dates')
84
+ const { json: record } = await read(plan.closeoutPath, 1024 * 1024)
85
+ if (record.journalId !== journal.id || record.source !== entry.sourceId || record.journalSha256 !== hash(bytes)) throw new Error('Closeout provenance mismatch')
86
+ const closedAt = readOperationalTime(record.closedAt)
87
+ if (Date.parse(closedAt) > now + skew * 1000 || Date.parse(closedAt) < Date.parse(start)) throw new Error('Invalid closeout clock')
88
+ if (JSON.stringify([...record.dates].sort()) !== JSON.stringify(dates)) throw new Error('Closeout dates differ')
89
+ const n = record.steps?.normalise
90
+ if (!n || n.unsupported || !Array.isArray(n.skipped) || n.skipped.length) return result('archive', 'degraded', 'archive-normalization-incomplete')
91
+ let files = 0
92
+ for (const d of dates) {
93
+ if (entry.sourceId === 'bulk-archive' && (!record.steps.packs?.[d] || record.steps.packs[d].error)) return result('archive', 'degraded', 'archive-packs-incomplete')
94
+ const upload = record.steps.upload?.[d]
95
+ if (!upload) return pending('archive', plan.deadlineAt, now, 'archive-upload')
96
+ for (const key of ['normalized', 'publish']) {
97
+ const u = upload[key]
98
+ if (key === 'publish' && u === null && entry.sourceId !== 'bulk-archive') continue
99
+ if (!u) return pending('archive', plan.deadlineAt, now, 'archive-upload')
100
+ if (u.error || u.stopped || u.failed > 0) return result('archive', 'degraded', 'archive-upload-failed')
101
+ if (![u.files, u.uploaded, u.skipped, u.failed].every(count) || u.failed !== 0 || u.files < 1 || u.uploaded + u.skipped !== u.files) throw new Error('Invalid upload accounting')
102
+ files += u.files
103
+ }
104
+ }
105
+ return result('archive', 'healthy', 'archive-closeout-verified', { latestPeriodEnd: end, lastSuccessAt: closedAt }, { 'recorded-upload-files': files })
106
+ }
107
+
108
+ async function publication(plan, read, now, skew) {
109
+ const { bytes: pointerBytes, json: pointer } = await read(plan.pointerPath, 8192)
110
+ if (pointer.schemaVersion !== 1 || pointer.kind !== 'feed-quality-release-pointer' || !digest(pointer.resultId) || pointer.operator !== plan.operator) throw new Error('Invalid release pointer')
111
+ const observedAt = readOperationalTime(pointer.observedAt), from = date(pointer.from), to = date(pointer.to)
112
+ if (Date.parse(observedAt) > now + skew * 1000 || from > to) throw new Error('Invalid publication clock/range')
113
+ // The caller selects the pointer; only a validated digest can select its sibling immutable artifact.
114
+ const dir = join(dirname(dirname(plan.pointerPath)), 'quality-releases', pointer.resultId)
115
+ const { json: manifest } = await read(join(dir, 'manifest.json'), 4096)
116
+ const file = manifest.files?.[0]
117
+ if (manifest.schemaVersion !== 1 || manifest.resultId !== pointer.resultId || manifest.files?.length !== 1 || file.path !== 'result.json' || !count(file.bytes) || !digest(file.sha256)) throw new Error('Invalid release manifest')
118
+ const { bytes, json: release } = await read(join(dir, 'result.json'), 2 * 1024 * 1024)
119
+ const { resultId, ...payload } = release
120
+ if (file.bytes !== bytes.length || file.sha256 !== hash(bytes) || resultId !== pointer.resultId || hash(JSON.stringify(payload)) !== resultId) throw new Error('Release integrity mismatch')
121
+ if (release.schemaVersion !== 2 || release.kind !== 'feed-quality-history' || release.operator !== plan.operator || release.from !== from || release.to !== to || !Array.isArray(release.days) || release.days.length > 366) throw new Error('Release selection mismatch')
122
+ if (!(await read(plan.pointerPath, 8192)).bytes.equals(pointerBytes)) throw new Error('Release selection changed during check')
123
+ const days = new Map()
124
+ for (const d of release.days) {
125
+ date(d.date)
126
+ if (days.has(d.date) || d.date < from || d.date > to) throw new Error('Invalid release day membership')
127
+ days.set(d.date, d)
128
+ }
129
+ if (from > plan.expectedThrough || to < plan.expectedThrough || !days.has(plan.expectedThrough)) return pending('publication', plan.deadlineAt, now, 'publication-period')
130
+ const selected = days.get(plan.expectedThrough)
131
+ const expected = studyDay(plan.expectedThrough)
132
+ if (Date.parse(plan.deadlineAt) < Date.parse(expected.endUtc) || Date.parse(expected.endUtc) > now + skew * 1000 || Date.parse(observedAt) < Date.parse(expected.endUtc)) throw new Error('Invalid publication deadline/period')
133
+ if (selected.status !== 'current') return pending('publication', plan.deadlineAt, now, 'publication-day')
134
+ if (!digest(selected.revision) || !selected.quality || selected.day?.serviceDate !== plan.expectedThrough || selected.day?.startUtc !== expected.startUtc || selected.day?.endUtc !== expected.endUtc) throw new Error('Invalid selected day')
135
+ return result('publication', 'healthy', 'publication-artifact-verified', { lastSuccessAt: observedAt, latestPeriodEnd: expected.endUtc }, { 'verified-release-bytes': bytes.length })
136
+ }
@@ -0,0 +1,3 @@
1
+ import type { FeedHealth } from './feed-observability.mjs'
2
+ /** Reads only supplied JSON. `now` is the observer's Unix time in milliseconds. */
3
+ export function recorderFeedHealth(registry: unknown, status: unknown, options: { producerId: string; now?: number }): FeedHealth
@@ -0,0 +1,106 @@
1
+ import { readFeedRegistry, readFeedHealth, readOperationalTime, summarizeFeedStages } from './feed-observability.mjs'
2
+
3
+ const finite = value => Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER
4
+ const time = value => { try { return readOperationalTime(value) } catch { return null } }
5
+ const age = (stamp, now) => (now - Date.parse(stamp)) / 1000
6
+ const stage = (stageId, state, reasons = [], timestamps = {}, metrics = {}) => ({ stageId, state, reasons, timestamps, metrics })
7
+ const metric = (key, value) => finite(value) ? { [key]: value } : {}
8
+
9
+ /** Projection of recorder status.json. Never read paths, emit raw errors or trust cached ages/state. */
10
+ export function recorderFeedHealth(registryValue, status, { producerId, now = Date.now() } = {}) {
11
+ const registry = readFeedRegistry(registryValue)
12
+ const observedAt = new Date(now).toISOString(), entries = registry.feeds.filter(f => f.producerId === producerId)
13
+ if (!entries.length) throw new Error('Unregistered producer')
14
+ if (entries.filter(f => f.stages.some(s => s.id === 'analytics')).length > 1) throw new Error('Recorder status supports one analytics feed')
15
+ const generatedAt = time(status?.generatedAt), reportAge = generatedAt ? age(generatedAt, now) : null
16
+ const telemetry = { state: status === null ? 'missing' : !generatedAt || reportAge < -registry.clockSkewSeconds ? 'invalid' : reportAge > registry.heartbeatMaxAgeSeconds ? 'stale' : 'current', ageSeconds: reportAge === null ? null : Math.max(0, reportAge) }
17
+ const unavailable = telemetry.state !== 'current'
18
+ if (!unavailable && (!Array.isArray(status.feeds) || status.feeds.length > 128 || new Set(status.feeds.map(f => f?.name)).size !== status.feeds.length)) throw new Error('Invalid recorder feed list')
19
+ const feeds = entries.map(entry => {
20
+ const f = status?.feeds?.find?.(f => f?.name === entry.producerFeed)
21
+ const stages = entry.stages.map(def => {
22
+ if (unavailable) return stage(def.id, 'unknown', [`telemetry-${telemetry.state}`])
23
+ if (entry.state !== 'active') return stage(def.id, 'unknown', [entry.state])
24
+ if (!f) return stage(def.id, 'unknown', ['feed-missing'])
25
+ if (status.storageError) return stage(def.id, 'degraded', ['host-storage-blocked'])
26
+ if (f.error) return stage(def.id, 'unknown', ['feed-status-unavailable'])
27
+ if (f.source !== entry.sourceId) return stage(def.id, 'unknown', [f.source ? 'source-mismatch' : 'source-unavailable'])
28
+ if (def.id === 'capture') return capture(def, f, registry, now, generatedAt)
29
+ if (def.id === 'normalize') return normalize(def, f, registry, now, generatedAt)
30
+ if (def.id === 'archive') {
31
+ // A complete close-out may be local-only. This report cannot prove an R2 upload.
32
+ const count = f.processing?.incompleteCloseouts, job = f.jobs?.[`process-${entry.producerFeed}`]
33
+ if (job && job.code !== 0) return stage(def.id, 'degraded', ['processing-job-failed'], {}, metric('incomplete-closeouts', count))
34
+ return stage(def.id, finite(count) && count > 0 ? 'waiting' : 'unknown', [finite(count) && count > 0 ? 'closeouts-pending' : 'upload-evidence-unavailable'], {}, metric('incomplete-closeouts', count))
35
+ }
36
+ if (def.id === 'analytics') return analytics(def, status.analytics, registry, now, generatedAt)
37
+ // Publication/serving needs separate evidence, even when a local job succeeded.
38
+ return stage(def.id, 'unknown', ['stage-evidence-unavailable'])
39
+ })
40
+ return { feedId: entry.id, configuredState: entry.state, state: entry.state === 'active' ? summarizeFeedStages(stages) : entry.state, stages }
41
+ })
42
+ const c = status?.capacity, disk = status?.disk
43
+ const capacity = unavailable ? { state: 'unknown', reasons: [`telemetry-${telemetry.state}`], metrics: {} } : {
44
+ state: status.storageError || c?.status === 'paused' || c?.error || c?.remainingCaptureBytes === 0 ? 'degraded' : c?.status === 'open' && finite(disk?.freeBytes) ? 'healthy' : 'unknown',
45
+ reasons: status.storageError ? ['host-storage-blocked'] : c?.error ? ['ledger-unavailable'] : c?.status === 'paused' ? ['ledger-paused'] : c?.remainingCaptureBytes === 0 ? ['capture-budget-exhausted'] : c?.status === 'open' && finite(disk?.freeBytes) ? [] : ['capacity-evidence-unavailable'],
46
+ metrics: { ...metric('disk-free-bytes', disk?.freeBytes), ...metric('disk-total-bytes', disk?.totalBytes), ...metric('committed-bytes', c?.usage?.total?.committed),
47
+ ...metric('reserved-bytes', c?.usage?.total?.reserved), ...metric('remaining-capture-bytes', c?.remainingCaptureBytes), ...metric('estimated-capture-days', c?.estimatedCaptureDays) },
48
+ }
49
+ return readFeedHealth({ schemaVersion: 1, kind: 'feed-health', registryId: registry.id, producerId, observedAt, producerGeneratedAt: generatedAt, telemetry, feeds, capacity }, registry)
50
+ }
51
+
52
+ function capture(def, f, registry, now, generatedAt) {
53
+ const receivedAt = time(f.lastCaptureAt)
54
+ if (!receivedAt) return stage(def.id, 'unknown', ['capture-time-unavailable'])
55
+ const receivedAge = age(receivedAt, now), timestamps = { receivedAt }, metrics = { 'capture-age-seconds': Math.max(0, receivedAge) }
56
+ if (receivedAge < -registry.clockSkewSeconds || Date.parse(receivedAt) > Date.parse(generatedAt) + registry.clockSkewSeconds * 1000) return stage(def.id, 'unknown', ['capture-clock-invalid'])
57
+ const reasons = []
58
+ if (def.maxAgeSeconds === null) return stage(def.id, 'unknown', ['capture-threshold-unconfigured'], timestamps, metrics)
59
+ if (receivedAge > def.maxAgeSeconds) reasons.push('capture-stale')
60
+ // Legacy host status stores source age at report time, not the original timestamp.
61
+ // Advancing it by report age prevents a fresh read from making old source data fresh.
62
+ if (finite(f.sourceAgeSeconds)) {
63
+ const sourceGeneratedAt = new Date(Date.parse(generatedAt) - f.sourceAgeSeconds * 1000)
64
+ if (!Number.isFinite(sourceGeneratedAt.getTime())) return stage(def.id, 'unknown', ['source-clock-invalid'], timestamps, metrics)
65
+ timestamps.sourceGeneratedAt = sourceGeneratedAt.toISOString()
66
+ metrics['source-age-seconds'] = Math.max(0, age(timestamps.sourceGeneratedAt, now))
67
+ if (def.sourceMaxAgeSeconds && metrics['source-age-seconds'] > def.sourceMaxAgeSeconds) reasons.push('source-stale')
68
+ } else if (def.sourceMaxAgeSeconds) {
69
+ return stage(def.id, reasons.length ? 'degraded' : 'unknown', [...reasons, 'source-time-unavailable'], timestamps, metrics)
70
+ }
71
+ if (f.plan?.action === 'blocked') reasons.push('collection-blocked')
72
+ if (reasons.length) return stage(def.id, 'degraded', reasons, timestamps, metrics)
73
+ if (['complete', 'stopped'].includes(f.state) || f.plan?.action === 'wait') return stage(def.id, 'waiting', ['collection-between-runs'], timestamps, metrics)
74
+ if (!['healthy', 'stalled'].includes(f.state)) return stage(def.id, 'unknown', ['collection-state-unrecognized'], timestamps, metrics)
75
+ return stage(def.id, 'healthy', [], timestamps, metrics)
76
+ }
77
+
78
+ function normalize(def, f, registry, now, generatedAt) {
79
+ const hour = f.processing?.latestNormalizedHour
80
+ const start = typeof hour === 'string' && /^\d{4}-\d\d-\d\dT\d\d$/.test(hour) ? time(`${hour}:00:00Z`) : null
81
+ if (!start) return stage(def.id, 'unknown', ['normalized-period-unavailable'])
82
+ const latestPeriodEnd = new Date(Date.parse(start) + 3600000).toISOString(), lag = age(latestPeriodEnd, now)
83
+ if (lag < -registry.clockSkewSeconds || Date.parse(latestPeriodEnd) > Date.parse(generatedAt) + registry.clockSkewSeconds * 1000) return stage(def.id, 'unknown', ['normalized-period-in-future'])
84
+ const timestamps = { latestPeriodEnd }, metrics = { 'normalization-lag-seconds': Math.max(0, lag) }
85
+ if (def.maxAgeSeconds === null) return stage(def.id, 'unknown', ['normalization-threshold-unconfigured'], timestamps, metrics)
86
+ return stage(def.id, lag > def.maxAgeSeconds ? 'degraded' : 'healthy', lag > def.maxAgeSeconds ? ['normalization-lagging'] : [], timestamps, metrics)
87
+ }
88
+
89
+ function analytics(def, a, registry, now, generatedAt) {
90
+ const attemptedAt = time(a?.observedAt)
91
+ if (!attemptedAt) return stage(def.id, 'unknown', ['analytics-status-unavailable'])
92
+ const elapsed = age(attemptedAt, now)
93
+ if (elapsed < -registry.clockSkewSeconds || Date.parse(attemptedAt) > Date.parse(generatedAt) + registry.clockSkewSeconds * 1000) return stage(def.id, 'unknown', ['analytics-clock-invalid'])
94
+ const timestamps = { lastAttemptAt: attemptedAt }
95
+ if (def.maxAgeSeconds === null) return stage(def.id, 'unknown', ['analytics-threshold-unconfigured'], timestamps)
96
+ if (elapsed > def.maxAgeSeconds) return stage(def.id, 'unknown', ['analytics-status-stale'], timestamps)
97
+ if (a.error || a.job && a.job.code !== 0) return stage(def.id, 'degraded', ['analytics-job-failed'], timestamps)
98
+ if (!Array.isArray(a.pendingMonths) || !Array.isArray(a.waiting) || !a.months || typeof a.months !== 'object' || Array.isArray(a.months) || Object.keys(a.months).length > 120) return stage(def.id, 'unknown', ['analytics-status-invalid'], timestamps)
99
+ const months = Object.values(a.months), successes = months.map(m => time(m?.lastSuccessAt)).filter(Boolean).sort()
100
+ if (successes.length) timestamps.lastSuccessAt = successes.at(-1)
101
+ if (timestamps.lastSuccessAt && (age(timestamps.lastSuccessAt, now) < -registry.clockSkewSeconds || Date.parse(timestamps.lastSuccessAt) > Date.parse(generatedAt) + registry.clockSkewSeconds * 1000)) return stage(def.id, 'unknown', ['analytics-clock-invalid'])
102
+ const metrics = { 'pending-months': a.pendingMonths.length, 'waiting-inputs': a.waiting.length }
103
+ // Open receipt hours normally remain waiting. A fresh worker check with no work is not a failed publication.
104
+ const waiting = a.pendingMonths.length > 0 || a.waiting.length > 0
105
+ return stage(def.id, waiting ? 'waiting' : successes.length ? 'healthy' : 'unknown', waiting ? ['analytics-awaiting-inputs-or-work'] : successes.length ? [] : ['analytics-success-unavailable'], timestamps, metrics)
106
+ }
@@ -0,0 +1,14 @@
1
+ import type { ReleaseDescriptor } from './release.mjs'
2
+ export interface ReleaseOpenOptions { kind?: string; schemaVersions?: readonly number[]; manifestSha256?: string; maxManifestBytes?: number; manifest?: string }
3
+ export interface OpenedRelease<M = Record<string, unknown>> {
4
+ manifest: M
5
+ manifestBytes: Buffer
6
+ descriptors: ReleaseDescriptor[]
7
+ /** Read a file the manifest describes, by path or descriptor, checking size and digest. */
8
+ read(file: string | ReleaseDescriptor, options?: { maxBytes?: number }): Promise<Buffer>
9
+ }
10
+ /** Bounded read without following a final symlink or blocking on a FIFO. */
11
+ export function readOperationalFile(path: string, limit: number): Promise<Buffer>
12
+ export function readReleaseFile(root: string, descriptor: ReleaseDescriptor, options?: { maxBytes?: number }): Promise<Buffer>
13
+ export function openRelease<M = Record<string, unknown>>(root: string, options?: ReleaseOpenOptions): Promise<OpenedRelease<M>>
14
+ export function verifyRelease<M = Record<string, unknown>>(root: string, options?: ReleaseOpenOptions & { maxTotalBytes?: number }): Promise<{ manifest: M; manifestSha256: string; files: number; bytes: number }>
@@ -0,0 +1,52 @@
1
+ import { realpath } from 'node:fs/promises'
2
+ import { join, relative, isAbsolute, resolve, sep } from 'node:path'
3
+ import { readOperationalFile } from './operational-files.mjs'
4
+ import { checkReleaseManifest, digestHex, isReleasePath, readDescriptor, releaseDescriptors, verifyDescriptorBytes } from './release.mjs'
5
+
6
+ export { readOperationalFile }
7
+ const inside = (root, path) => { const r = relative(root, path); return r !== '' && !r.startsWith(`..${sep}`) && r !== '..' && !isAbsolute(r) }
8
+
9
+ /**
10
+ * Read one described file from a release directory: safe path, bounded, size and digest checked. No component below
11
+ * the release root may be a symbolic link, even one pointing elsewhere inside it, so a described path always names
12
+ * exactly the file that was written there.
13
+ */
14
+ export async function readReleaseFile(root, descriptor, { maxBytes = 512 * 1024 ** 2 } = {}) {
15
+ const d = readDescriptor(descriptor)
16
+ if (d.bytes > maxBytes) throw new Error(`${d.path} exceeds the read limit`)
17
+ const base = await realpath(resolve(root)), target = join(base, ...d.path.split('/')), actual = await realpath(target)
18
+ if (!inside(base, actual)) throw new Error(`${d.path} resolves outside the release`)
19
+ if (actual !== target) throw new Error(`${d.path} passes through a symbolic link`)
20
+ return verifyDescriptorBytes(await readOperationalFile(actual, d.bytes), d)
21
+ }
22
+
23
+ /**
24
+ * Open a release directory: read `manifest.json` (bounded), optionally require its pinned digest, check its kind and
25
+ * schema, and enumerate its files. Nothing but the manifest is read until asked.
26
+ */
27
+ export async function openRelease(root, { kind, schemaVersions, manifestSha256, maxManifestBytes = 8 * 1024 ** 2, manifest: name = 'manifest.json' } = {}) {
28
+ if (!isReleasePath(name)) throw new Error('Unsafe manifest path')
29
+ const bytes = await readOperationalFile(join(resolve(root), name), maxManifestBytes)
30
+ if (manifestSha256 !== undefined && await digestHex(bytes) !== manifestSha256) throw new Error('Release manifest does not match its pinned digest')
31
+ const manifest = checkReleaseManifest(JSON.parse(bytes.toString('utf8')), { kind, schemaVersions })
32
+ const descriptors = releaseDescriptors(manifest), byPath = new Map(descriptors.map(d => [d.path, d]))
33
+ return {
34
+ manifest, manifestBytes: bytes, descriptors,
35
+ /** Read a described file by path or descriptor; a path the manifest does not list is refused. */
36
+ read: async (file, options) => {
37
+ const d = typeof file === 'string' ? byPath.get(file) : file
38
+ const listed = d && byPath.get(d.path)
39
+ if (!listed || listed.sha256 !== d.sha256 || listed.bytes !== d.bytes) throw new Error(`${typeof file === 'string' ? file : file?.path} is not described by the manifest`)
40
+ return readReleaseFile(root, d, options)
41
+ },
42
+ }
43
+ }
44
+
45
+ /** Verify every file a release describes against a total byte budget. */
46
+ export async function verifyRelease(root, { maxTotalBytes = 2 * 1024 ** 3, ...options } = {}) {
47
+ const release = await openRelease(root, options)
48
+ const total = release.descriptors.reduce((n, d) => n + d.bytes, 0)
49
+ if (total > maxTotalBytes) throw new Error('Release exceeds the verification budget')
50
+ for (const d of release.descriptors) await readReleaseFile(root, d)
51
+ return { manifest: release.manifest, manifestSha256: await digestHex(release.manifestBytes), files: release.descriptors.length, bytes: total }
52
+ }
package/release.d.mts ADDED
@@ -0,0 +1,10 @@
1
+ /** A file a release describes: its release-relative path and the size and SHA-256 of its stored bytes. */
2
+ export interface ReleaseDescriptor { path: string; sha256: string; bytes: number; [field: string]: unknown }
3
+ export declare const RELEASE_DIGEST: RegExp
4
+ export function isReleasePath(path: unknown): path is string
5
+ export function readDescriptor(value: unknown, label?: string): ReleaseDescriptor
6
+ /** Every descriptor in a manifest's `files` (array or map), `objects`, `chunks`, `list` and named top-level descriptors, sorted by path. */
7
+ export function releaseDescriptors(manifest: unknown): ReleaseDescriptor[]
8
+ export function checkReleaseManifest<T extends object>(manifest: T, options: { kind?: string; schemaVersions?: readonly number[] }): T
9
+ export function digestHex(bytes: ArrayBuffer | ArrayBufferView): Promise<string>
10
+ export function verifyDescriptorBytes<T extends ArrayBuffer | ArrayBufferView>(bytes: T, descriptor: ReleaseDescriptor): Promise<T>
package/release.mjs ADDED
@@ -0,0 +1,62 @@
1
+ // Shared checks for versioned release handoffs. Runs in Node and in browsers (Web Crypto only).
2
+
3
+ export const RELEASE_DIGEST = /^[a-f0-9]{64}$/
4
+ const SEGMENT = /^[A-Za-z0-9._-]{1,255}$/
5
+
6
+ /** A release-relative path: forward-slash segments of safe characters, never absolute, empty, `.` or `..`. */
7
+ export function isReleasePath(path) {
8
+ return typeof path === 'string' && path.length <= 1024 && path.split('/').every(s => SEGMENT.test(s) && s !== '.' && s !== '..')
9
+ }
10
+
11
+ /** Validate one `{ path, sha256, bytes }` file descriptor; extra fields such as `decodedBytes` are kept. */
12
+ export function readDescriptor(value, label = 'file') {
13
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`Invalid ${label} descriptor`)
14
+ if (!isReleasePath(value.path)) throw new Error(`Unsafe ${label} path`)
15
+ if (!RELEASE_DIGEST.test(value.sha256 ?? '')) throw new Error(`Invalid ${label} digest`)
16
+ if (!Number.isSafeInteger(value.bytes) || value.bytes < 0) throw new Error(`Invalid ${label} size`)
17
+ return value
18
+ }
19
+
20
+ const looksLikeDescriptor = v => v && typeof v === 'object' && !Array.isArray(v) && typeof v.path === 'string' && 'sha256' in v && 'bytes' in v
21
+ /**
22
+ * Every file a manifest describes, whatever its layout: a `files` array or name-keyed map, an `objects` map keyed by
23
+ * digest, `chunks`, a published bus day's `list`, and named top-level descriptors such as `day` or `index`. The same
24
+ * path may appear more than once (air releases list chunks in `files` and `chunks`) but must then describe the same
25
+ * bytes.
26
+ */
27
+ export function releaseDescriptors(manifest) {
28
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) throw new Error('Invalid release manifest')
29
+ const found = new Map()
30
+ const add = (value, label) => {
31
+ const d = readDescriptor(value, label), seen = found.get(d.path)
32
+ if (seen && (seen.sha256 !== d.sha256 || seen.bytes !== d.bytes)) throw new Error(`Conflicting descriptors for ${d.path}`)
33
+ if (!seen) found.set(d.path, d)
34
+ }
35
+ for (const [key, value] of Object.entries(manifest)) {
36
+ if (Array.isArray(value) && ['files', 'chunks', 'list'].includes(key)) value.forEach((v, i) => add(v, `${key}[${i}]`))
37
+ else if (['files', 'objects'].includes(key) && value && typeof value === 'object' && !Array.isArray(value)) for (const [name, v] of Object.entries(value)) add(v, `${key}.${name}`)
38
+ else if (looksLikeDescriptor(value)) add(value, key)
39
+ }
40
+ return [...found.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
41
+ }
42
+
43
+ /** Check a manifest's identity before trusting its layout. */
44
+ export function checkReleaseManifest(manifest, { kind, schemaVersions }) {
45
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) throw new Error('Invalid release manifest')
46
+ if (kind !== undefined && manifest.kind !== kind) throw new Error(`Expected a ${kind} release`)
47
+ if (schemaVersions !== undefined && !schemaVersions.includes(manifest.schemaVersion)) throw new Error(`Unsupported ${manifest.kind ?? 'release'} schema version`)
48
+ return manifest
49
+ }
50
+
51
+ const hex = buffer => Array.from(new Uint8Array(buffer), b => b.toString(16).padStart(2, '0')).join('')
52
+ /** SHA-256 of bytes as lowercase hex, through Web Crypto. */
53
+ export async function digestHex(bytes) {
54
+ return hex(await globalThis.crypto.subtle.digest('SHA-256', bytes))
55
+ }
56
+ /** Throw unless `bytes` are exactly the descriptor's size and digest. */
57
+ export async function verifyDescriptorBytes(bytes, descriptor) {
58
+ const d = readDescriptor(descriptor)
59
+ if (bytes.byteLength !== d.bytes) throw new Error(`${d.path} has ${bytes.byteLength} bytes, expected ${d.bytes}`)
60
+ if (await digestHex(bytes) !== d.sha256) throw new Error(`${d.path} does not match its digest`)
61
+ return bytes
62
+ }
@@ -0,0 +1,15 @@
1
+ import type {Buffer} from 'node:buffer'
2
+ export interface CaptureSource {id:string;publisher:string;kind:string;documentation:string;license:string;attribution?:string;origins:readonly string[]}
3
+ export interface CaptureRecord {schemaVersion:1;source:Omit<CaptureSource,'origins'>;url:string;sha256:string;bytes:number;storedBytes?:number;encoding?:'gzip'|null;retrievedAt:string|null;importedAt?:string;acquisition:'http'|'local-import';validation:string;httpStatus?:number;contentType?:string|null;lastModified?:string|null;etag?:string|null}
4
+ export interface CaptureResult {bytes:Buffer;record:CaptureRecord;cached:boolean}
5
+ export type CaptureValidator=(bytes:Buffer)=>unknown|Promise<unknown>
6
+ export interface CaptureOptions {store:string;source:CaptureSource;url:string;offline?:boolean;refresh?:boolean;maxBytes?:number;timeoutMs?:number;retries?:number;validate?:CaptureValidator;fetchImpl?:typeof fetch;userAgent?:string;compression?:'gzip'}
7
+ export function sha256(bytes:string|Uint8Array):string
8
+ export function publicSourceUrl(source:CaptureSource,value:string):string
9
+ export function atomicJson(path:string,value:unknown):Promise<void>
10
+ export function readCapture(store:string,source:CaptureSource,url:string,validate?:CaptureValidator):Promise<CaptureResult>
11
+ export function importCapture(options:Pick<CaptureOptions,'store'|'source'|'url'|'maxBytes'|'validate'|'compression'>&{input:string}):Promise<CaptureResult>
12
+ export function readObject(store:string,hash:string,expectedBytes?:number):Promise<Buffer>
13
+ export function compactObjects(store:string,options?:{minimumSaving?:number}):Promise<{examined:number;compressed:number;kept:number;rawBytes:number;storedBytes:number}>
14
+ export function capture(options:CaptureOptions):Promise<CaptureResult>
15
+ export function jsonDocument(bytes:Buffer):Record<string,unknown>|unknown[]
@@ -0,0 +1,160 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { mkdir, readFile, writeFile, rename, rm, stat, readdir, unlink } from 'node:fs/promises'
3
+ import { join, dirname } from 'node:path'
4
+ import { setTimeout as delay } from 'node:timers/promises'
5
+ import { gzipSync, gunzipSync } from 'node:zlib'
6
+
7
+ export const sha256 = bytes => createHash('sha256').update(bytes).digest('hex')
8
+
9
+ export function publicSourceUrl(source, value) {
10
+ const url = new URL(value)
11
+ if (url.protocol !== 'https:' || url.username || url.password || url.hash ||
12
+ [...url.searchParams.keys()].some(key => /key|token|password|secret|signature|credential/i.test(key))) {
13
+ throw new Error('Source URLs must be public HTTPS URLs without credentials or fragments')
14
+ }
15
+ if (!source.origins.includes(url.origin)) throw new Error(`URL origin is not configured for ${source.id}`)
16
+ return url.href
17
+ }
18
+
19
+ export async function atomicJson(path, value) {
20
+ await atomicWrite(path, Buffer.from(`${JSON.stringify(value, null, 2)}\n`))
21
+ }
22
+
23
+ async function atomicWrite(path, bytes) {
24
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 })
25
+ const temporary = `${path}.${randomUUID()}.partial`
26
+ try {
27
+ await writeFile(temporary, bytes, { mode: 0o600 })
28
+ await rename(temporary, path)
29
+ } finally {
30
+ await rm(temporary, { force: true })
31
+ }
32
+ }
33
+
34
+ function requestKey(source, url) { return sha256(`${source.id}\n${url}`) }
35
+
36
+ export async function readCapture(store, source, url, validate = () => {}) {
37
+ const record = JSON.parse(await readFile(join(store, 'requests', `${requestKey(source, url)}.json`), 'utf8'))
38
+ if (record.source.id !== source.id || record.url !== url || !/^[a-f0-9]{64}$/.test(record.sha256)) throw new Error('Capture identity mismatch')
39
+ const bytes = await readObject(store, record.sha256, record.bytes)
40
+ if (bytes.length !== record.bytes || sha256(bytes) !== record.sha256) throw new Error('Capture integrity check failed')
41
+ await validate(bytes)
42
+ return { bytes, record, cached: true }
43
+ }
44
+ /** Read an object by its raw-content hash, whether it is stored raw or gzip-compressed at rest. */
45
+ export async function readObject(store, hash, expectedBytes) {
46
+ try { return await readFile(join(store, 'objects', hash)) } catch (error) { if (error.code !== 'ENOENT') throw error }
47
+ const compressed = await readFile(join(store, 'objects', `${hash}.gz`))
48
+ try { return gunzipSync(compressed, { maxOutputLength: (expectedBytes ?? 512 * 1024 * 1024) + 1 }) } catch { throw new Error('Compressed capture could not be expanded within its recorded size') }
49
+ }
50
+ /** Store object bytes, compressing at rest when asked and worthwhile; the hash of the raw bytes remains the identity. */
51
+ async function writeObject(store, hash, bytes, compression) {
52
+ if (compression === 'gzip') {
53
+ const compressed = gzipSync(bytes, { level: 6 })
54
+ if (compressed.length <= bytes.length * 0.9) { await atomicWrite(join(store, 'objects', `${hash}.gz`), compressed); return { storedBytes: compressed.length, encoding: 'gzip' } }
55
+ } else if (compression !== undefined) throw new Error('Unsupported compression; use gzip or leave unset')
56
+ await atomicWrite(join(store, 'objects', hash), bytes)
57
+ return { storedBytes: bytes.length, encoding: null }
58
+ }
59
+ /** Compress existing raw objects in a store where gzip saves at least the given fraction. Records keep their raw hash and byte count; reads follow either form. */
60
+ export async function compactObjects(store, { minimumSaving = 0.1 } = {}) {
61
+ const result = { examined: 0, compressed: 0, kept: 0, rawBytes: 0, storedBytes: 0 }
62
+ let names
63
+ try { names = await readdir(join(store, 'objects')) } catch (error) { if (error.code === 'ENOENT') return result; throw error }
64
+ for (const name of names.filter(n => /^[a-f0-9]{64}$/.test(n))) {
65
+ const bytes = await readFile(join(store, 'objects', name))
66
+ result.examined++; result.rawBytes += bytes.length
67
+ const compressed = gzipSync(bytes, { level: 6 })
68
+ if (compressed.length <= bytes.length * (1 - minimumSaving)) {
69
+ await atomicWrite(join(store, 'objects', `${name}.gz`), compressed)
70
+ if (sha256(gunzipSync(compressed)) !== name) throw new Error('Compressed object failed verification; raw object retained')
71
+ await unlink(join(store, 'objects', name)); result.compressed++; result.storedBytes += compressed.length
72
+ } else { result.kept++; result.storedBytes += bytes.length }
73
+ }
74
+ return result
75
+ }
76
+
77
+ async function persist(store, source, url, bytes, acquisition, response = {}, compression) {
78
+ const hash = sha256(bytes)
79
+ const stored = await writeObject(store, hash, bytes, compression)
80
+ const record = {
81
+ schemaVersion: 1, source: { id: source.id, publisher: source.publisher, kind: source.kind, documentation: source.documentation, license: source.license, ...(source.attribution ? { attribution: source.attribution } : {}) },
82
+ url, sha256: hash, bytes: bytes.length, storedBytes: stored.storedBytes, encoding: stored.encoding,
83
+ ...(acquisition === 'http' ? { retrievedAt: new Date().toISOString() } : { retrievedAt: null, importedAt: new Date().toISOString() }),
84
+ acquisition, validation: 'raw-capture; see adapter audit for fitness', ...response,
85
+ }
86
+ await atomicJson(join(store, 'records', `${randomUUID()}.json`), record)
87
+ await atomicJson(join(store, 'requests', `${requestKey(source, url)}.json`), record)
88
+ return { bytes, record, cached: false }
89
+ }
90
+
91
+ export async function importCapture({ store, source, url, input, maxBytes = 512 * 1024 * 1024, validate = () => {}, compression }) {
92
+ url = publicSourceUrl(source, url)
93
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new Error('Expected a positive byte limit')
94
+ if ((await stat(input)).size > maxBytes) throw new Error('Source file exceeds byte limit')
95
+ const bytes = await readFile(input)
96
+ if (!bytes.length || bytes.length > maxBytes) throw new Error('Source file is empty or exceeds byte limit')
97
+ await validate(bytes)
98
+ return persist(store, source, url, bytes, 'local-import', {}, compression)
99
+ }
100
+
101
+ export async function capture({ store, source, url, offline = false, refresh = false, maxBytes = 32 * 1024 * 1024, timeoutMs = 20000, retries = 2, validate = () => {}, fetchImpl = fetch, userAgent = 'Motion Studies source capture', compression }) {
102
+ url = publicSourceUrl(source, url)
103
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || !Number.isSafeInteger(retries) || retries < 0 || retries > 10) throw new Error('Invalid capture limits')
104
+ if (offline && refresh) throw new Error('Offline and refresh cannot be combined')
105
+ if (!refresh) {
106
+ try { return await readCapture(store, source, url, validate) }
107
+ catch (error) { if (error.code !== 'ENOENT') throw error }
108
+ }
109
+ if (offline) throw new Error(`No cached capture for ${source.id}; offline mode never downloads`)
110
+ for (let attempt = 0; attempt <= retries; attempt++) {
111
+ let retryable = true
112
+ try {
113
+ const response = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs), headers: { 'User-Agent': userAgent } })
114
+ if (response.status === 204) {
115
+ retryable = false
116
+ throw new Error('Source returned HTTP 204: no report content; this does not mean zero traffic')
117
+ }
118
+ if (!response.ok) {
119
+ retryable = response.status === 429 || response.status >= 500
120
+ await response.body?.cancel()
121
+ throw new Error(`Source returned HTTP ${response.status}`)
122
+ }
123
+ if (Number(response.headers.get('content-length')) > maxBytes) {
124
+ retryable = false
125
+ await response.body?.cancel()
126
+ throw new Error('Source exceeds byte limit')
127
+ }
128
+ if (!response.body) throw new Error('Source response has no body')
129
+ const reader = response.body.getReader()
130
+ const chunks = []
131
+ let size = 0
132
+ try {
133
+ while (true) {
134
+ const { value, done } = await reader.read()
135
+ if (done) break
136
+ size += value.length
137
+ if (size > maxBytes) { retryable = false; await reader.cancel(); throw new Error('Source exceeds byte limit') }
138
+ chunks.push(value)
139
+ }
140
+ } finally { reader.releaseLock() }
141
+ const bytes = Buffer.concat(chunks)
142
+ retryable = false
143
+ if (!bytes.length) throw new Error('Source response is empty')
144
+ await validate(bytes)
145
+ return await persist(store, source, url, bytes, 'http', {
146
+ httpStatus: response.status, contentType: response.headers.get('content-type'),
147
+ lastModified: response.headers.get('last-modified'), etag: response.headers.get('etag'),
148
+ }, compression)
149
+ } catch (error) {
150
+ if (!retryable || attempt === retries) throw error
151
+ await delay(250 * 2 ** attempt)
152
+ }
153
+ }
154
+ }
155
+
156
+ export function jsonDocument(bytes) {
157
+ const value = JSON.parse(bytes.toString('utf8'))
158
+ if (!value || typeof value !== 'object') throw new Error('Expected a JSON document')
159
+ return value
160
+ }
@@ -0,0 +1,7 @@
1
+ export declare const SERVICE_TIMEZONE: 'Europe/London'
2
+ /** UK civil day boundaries. GTFS service time has a separate noon-based origin. */
3
+ export interface UkStudyDay {serviceDate:string;timezone:'Europe/London';startUtc:string;endUtc:string;durationSeconds:number}
4
+ export function checkedDate(value:string):string
5
+ export function studyDay(serviceDate:string):UkStudyDay
6
+ export function utcDatesForDay(day:UkStudyDay):string[]
7
+ export function gtfsServiceInstant(serviceDate:string,seconds:number):string
@@ -0,0 +1,49 @@
1
+ export const SERVICE_TIMEZONE = 'Europe/London'
2
+
3
+ export function checkedDate(value) {
4
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value ?? '') || !Number.isFinite(Date.parse(`${value}T00:00:00Z`)) ||
5
+ new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) !== value) {
6
+ throw new Error('An actual service date in YYYY-MM-DD form is required')
7
+ }
8
+ return value
9
+ }
10
+
11
+ // UK midnight is unambiguous on both DST transition days. Never infer a full day as 86400 seconds.
12
+ function londonMidnight(date) {
13
+ const nominal = Date.parse(`${date}T00:00:00Z`)
14
+ const formatter = new Intl.DateTimeFormat('en-GB', { timeZone: SERVICE_TIMEZONE, timeZoneName: 'longOffset' })
15
+ let instant = nominal
16
+ for (let attempt = 0; attempt < 3; attempt++) {
17
+ const offset = formatter.formatToParts(instant).find(part => part.type === 'timeZoneName').value
18
+ const match = /^GMT([+-])(\d{2}):(\d{2})$/.exec(offset)
19
+ if (offset !== 'GMT' && !match) throw new Error('Unsupported timezone offset')
20
+ const minutes = match ? (match[1] === '+' ? 1 : -1) * (Number(match[2]) * 60 + Number(match[3])) : 0
21
+ instant = nominal - minutes * 60000
22
+ }
23
+ return instant
24
+ }
25
+
26
+ export function studyDay(serviceDate) {
27
+ checkedDate(serviceDate)
28
+ const next = new Date(Date.parse(`${serviceDate}T00:00:00Z`) + 86400000).toISOString().slice(0, 10)
29
+ const start = londonMidnight(serviceDate)
30
+ const end = londonMidnight(next)
31
+ return { serviceDate, timezone: SERVICE_TIMEZONE, startUtc: new Date(start).toISOString(), endUtc: new Date(end).toISOString(), durationSeconds: (end - start) / 1000 }
32
+ }
33
+
34
+ export function utcDatesForDay(day) {
35
+ const first = Date.parse(day.startUtc.slice(0, 10))
36
+ const last = Date.parse(new Date(Date.parse(day.endUtc) - 1).toISOString().slice(0, 10))
37
+ const dates = []
38
+ for (let time = first; time <= last; time += 86400000) dates.push(new Date(time).toISOString().slice(0, 10))
39
+ return dates
40
+ }
41
+
42
+ // GTFS defines service time from local noon minus twelve elapsed hours. On UK
43
+ // DST transition days this differs from the observed civil-day midnight.
44
+ export function gtfsServiceInstant(serviceDate, seconds) {
45
+ if (!Number.isSafeInteger(seconds) || seconds < 0) throw new Error('GTFS seconds must be a nonnegative whole number')
46
+ const day = studyDay(serviceDate)
47
+ const noonMinusTwelveHours = Date.parse(day.endUtc) - 86400000
48
+ return new Date(noonMinusTwelveHours + seconds * 1000).toISOString()
49
+ }