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

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 +193 -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,148 @@
1
+ import { readFile, readdir, writeFile } from 'node:fs/promises'
2
+ import { dirname, join } from 'node:path'
3
+ import { gunzipSync } from 'node:zlib'
4
+ import { createHash } from 'node:crypto'
5
+ import { isAirContinent } from '@motionstudies/core/air-continents'
6
+ import { parseCsvLine } from './gtfs.mjs'
7
+ import { decodeAdsbHeatmap } from './adsb-heatmap.mjs'
8
+
9
+ export function parseAirports(csv, { continentOverrides = [] } = {}) {
10
+ const [header, ...lines] = csv.trim().split(/\r?\n/)
11
+ const keys = parseCsvLine(header)
12
+ const rows = lines.map(line => Object.fromEntries(parseCsvLine(line).map((value, index) => [keys[index], value])))
13
+ if (!Array.isArray(continentOverrides)) throw new Error('Continent overrides must be an array')
14
+ const identifiers = new Set(rows.map(row => row.ident)), overrides = new Map()
15
+ for (const entry of continentOverrides) {
16
+ if (!entry || !identifiers.has(entry.airportIdent) || !isAirContinent(entry.continent)
17
+ || typeof entry.reason !== 'string' || !entry.reason.trim() || overrides.has(entry.airportIdent)) {
18
+ throw new Error('Each continent override needs a unique reference airportIdent, valid continent and reason')
19
+ }
20
+ overrides.set(entry.airportIdent, entry.continent)
21
+ }
22
+ const geography = row => {
23
+ const continent = overrides.get(row.ident) ?? row.continent?.trim().toUpperCase()
24
+ if (!continent) return {}
25
+ if (!isAirContinent(continent)) throw new Error(`Invalid continent for airport ${row.ident}: ${continent}`)
26
+ return { continent, continentSource: overrides.has(row.ident) ? 'override' : 'ourairports' }
27
+ }
28
+ return rows
29
+ .filter(row => ['large_airport', 'medium_airport'].includes(row.type) && row.ident && row.elevation_ft)
30
+ .map(row => ({ icao: row.ident, iata: row.iata_code, name: row.name, city: row.municipality,
31
+ longitude: Number(row.longitude_deg), latitude: Number(row.latitude_deg), elevation: Number(row.elevation_ft), ...geography(row) }))
32
+ .filter(row => [row.longitude, row.latitude, row.elevation].every(Number.isFinite))
33
+ }
34
+
35
+ function distance(sample, airport) {
36
+ const longitude = ((sample[1] - airport.longitude + 540) % 360) - 180
37
+ return Math.hypot(longitude * 111.32 * Math.cos((sample[2] + airport.latitude) * Math.PI / 360), (sample[2] - airport.latitude) * 111.32)
38
+ }
39
+
40
+ function atAirport(sample, airport) {
41
+ return sample[3] >= airport.elevation - 500 && sample[3] <= airport.elevation + 1500 && sample[4] <= 250 && distance(sample, airport) <= 6
42
+ }
43
+
44
+ /** Conservative endpoint association; cruise-only traces and ambiguous neighbouring airports stay unknown. */
45
+ export function inferAirEndpoints(samples, airports) {
46
+ if (samples.length < 4) return {}
47
+ const maximumAltitude = Math.max(...samples.map(sample => sample[3]))
48
+ const endpoint = (arrival) => {
49
+ const ordered = arrival ? [...samples].reverse() : samples
50
+ const candidates = airports.filter(airport => atAirport(ordered[0], airport) && maximumAltitude >= airport.elevation + 3000)
51
+ if (candidates.length !== 1) return undefined
52
+ const airport = candidates[0]
53
+ let boundary = ordered[0]
54
+ for (const sample of ordered.slice(1)) {
55
+ if (!atAirport(sample, airport)) break
56
+ boundary = sample
57
+ }
58
+ return { icao: airport.icao, iata: airport.iata, name: airport.name, city: airport.city, time: boundary[0], evidence: 'observed-endpoint',
59
+ ...(isAirContinent(airport.continent) ? { continent: airport.continent, continentSource: airport.continentSource ?? 'ourairports' } : {}) }
60
+ }
61
+ const origin = endpoint(false), destination = endpoint(true)
62
+ if (origin && destination && (origin.icao === destination.icao || origin.time >= destination.time)) return {}
63
+ return { ...(origin ? { origin } : {}), ...(destination ? { destination } : {}) }
64
+ }
65
+
66
+ function segmentsFor(samples) {
67
+ const segments = []
68
+ let current = []
69
+ for (const sample of samples.sort((a, b) => a[0] - b[0])) {
70
+ const previous = current.at(-1)
71
+ if (previous && previous[0] === sample[0]) continue
72
+ if (previous && (sample[0] - previous[0] > 1800 || sample[5] !== previous[5])) {
73
+ segments.push(current)
74
+ current = []
75
+ }
76
+ current.push(sample)
77
+ }
78
+ if (current.length) segments.push(current)
79
+ return segments
80
+ }
81
+
82
+ /** Enrich an existing study and its chunks using cached, same-date global heatmaps. No network requests. */
83
+ export async function enrichAirEndpoints({ manifestPath, snapshotPaths = [], heatmapDirectory, airportCsvPath, utcOffsetHours }) {
84
+ const readJson = async path => JSON.parse(await readFile(path, 'utf8'))
85
+ const manifest = await readJson(manifestPath)
86
+ const snapshots = await Promise.all(snapshotPaths.map(readJson))
87
+ const date = manifest.metadata.serviceDate
88
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isFinite(utcOffsetHours)) throw new Error('A service date and UTC offset are required')
89
+ if (snapshots.some(snapshot => snapshot.metadata.serviceDate !== date)) throw new Error('Air snapshots must share the manifest service date')
90
+ const tracks = [...manifest.aircraft, ...snapshots.flatMap(snapshot => snapshot.tracks)]
91
+ const wanted = new Set(tracks.map(track => parseInt(track.icaoAddress ?? track.id.split('-')[0], 16)))
92
+ const records = new Map()
93
+ const files = (await readdir(heatmapDirectory)).filter(file => file.endsWith('.bin.ttf')).sort()
94
+ if (!files.length) throw new Error('No cached heatmap slices found')
95
+ const sources = []
96
+ for (const file of files) {
97
+ const raw = await readFile(join(heatmapDirectory, file))
98
+ const used = decodeAdsbHeatmap(gunzipSync(raw), {
99
+ serviceDate: date, utcOffsetHours, windowStart: 0, windowEnd: 86400, addresses: wanted,
100
+ }, (id, sample) => {
101
+ const samples = records.get(id) ?? []
102
+ sample[5] ??= samples.at(-1)?.[5] ?? ''
103
+ samples.push(sample)
104
+ records.set(id, samples)
105
+ })
106
+ if (used) sources.push({ file, sha256: createHash('sha256').update(raw).digest('hex') })
107
+ }
108
+ const csv = await readFile(airportCsvPath, 'utf8')
109
+ if (!sources.length) throw new Error('No same-date observations matched the study aircraft')
110
+ const airports = parseAirports(csv)
111
+ if (!airports.length) throw new Error('No usable airports in the reference CSV')
112
+ const segments = new Map([...records].map(([id, samples]) => [id, segmentsFor(samples).map(samples => ({
113
+ start: samples[0][0], end: samples.at(-1)[0], callsign: samples[0][5], ...inferAirEndpoints(samples, airports),
114
+ }))]))
115
+ const enrich = (track) => {
116
+ const candidates = (segments.get(parseInt(track.icaoAddress ?? track.id.split('-')[0], 16)) ?? [])
117
+ .filter(segment => segment.callsign && segment.callsign === track.callsign && segment.start <= track.start + 30 && segment.end >= track.end - 30)
118
+ const { origin: _origin, destination: _destination, ...rest } = track
119
+ if (candidates.length !== 1) return rest
120
+ const { origin, destination } = candidates[0]
121
+ return { ...rest, ...(origin ? { origin } : {}), ...(destination ? { destination } : {}) }
122
+ }
123
+ const metadata = {
124
+ serviceDate: date, method: 'observed-endpoint', note: 'Origin/destination inferred from full-trace endpoints within 6 km and 1500 ft above an airport; observed times, not schedules or confirmed flight plans.',
125
+ airportSource: 'https://ourairports.com/data/', airportLicense: 'Public Domain', airportSha256: createHash('sha256').update(csv).digest('hex'),
126
+ traceSource: manifest.metadata.sourceUrl, traceLicense: 'ODbL 1.0', utcOffsetHours, sourceFiles: sources,
127
+ }
128
+ const aircraft = manifest.aircraft.map(enrich)
129
+ const byId = new Map(aircraft.map(track => [track.id, track]))
130
+ const writes = []
131
+ for (const descriptor of manifest.chunks) {
132
+ const path = join(dirname(manifestPath), descriptor.path)
133
+ const chunk = await readJson(path)
134
+ chunk.tracks = chunk.tracks.map(track => {
135
+ const { origin: _origin, destination: _destination, ...rest } = track
136
+ const full = byId.get(track.id)
137
+ return { ...rest, ...(full?.origin ? { origin: full.origin } : {}), ...(full?.destination ? { destination: full.destination } : {}) }
138
+ })
139
+ const bytes = Buffer.from(`${JSON.stringify(chunk)}\n`)
140
+ if ('bytes' in descriptor) descriptor.bytes = bytes.length
141
+ if ('sha256' in descriptor) descriptor.sha256 = createHash('sha256').update(bytes).digest('hex')
142
+ writes.push([path, chunk])
143
+ }
144
+ writes.push([manifestPath, { ...manifest, metadata: { ...manifest.metadata, routeEnrichment: metadata }, aircraft }])
145
+ snapshots.forEach((snapshot, index) => writes.push([snapshotPaths[index], { ...snapshot, metadata: { ...snapshot.metadata, routeEnrichment: metadata }, tracks: snapshot.tracks.map(enrich) }]))
146
+ for (const [path, value] of writes) await writeFile(path, `${JSON.stringify(value)}\n`)
147
+ return { tracks: aircraft.length, origins: aircraft.filter(track => track.origin).length, destinations: aircraft.filter(track => track.destination).length }
148
+ }
@@ -0,0 +1,3 @@
1
+ export interface DailyExpectation { serviceDate: string; deadlineAt: string; nextDeadlineAt: string }
2
+ /** Repeated local minutes use their later occurrence; missing minutes use the next valid minute. */
3
+ export function dailyFeedExpectation(schedule: { timeZone: string; deadline: string }, now?: number): DailyExpectation
@@ -0,0 +1,33 @@
1
+ import { readOperationalTime } from './feed-observability.mjs'
2
+
3
+ const shift = (date, days) => new Date(Date.parse(`${date}T00:00:00Z`) + days * 86400000).toISOString().slice(0, 10)
4
+ function formatter(timeZone) {
5
+ return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
6
+ }
7
+ function wall(format, ms) {
8
+ const p = Object.fromEntries(format.formatToParts(ms).map(p => [p.type, p.value]))
9
+ return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}`
10
+ }
11
+ /** Latest occurrence of a repeated local minute; first valid minute after a DST gap. */
12
+ function deadlineAt(date, deadline, format) {
13
+ const target = `${date}T${deadline}`, nominal = Date.parse(`${target}:00Z`)
14
+ let exact = null, next = null
15
+ for (let ms = nominal - 18 * 3600000; ms <= nominal + 18 * 3600000; ms += 60000) {
16
+ const local = wall(format, ms)
17
+ if (local === target) exact = ms
18
+ if (local.slice(0, 10) === date && local > target && (!next || local < next.local)) next = { local, ms }
19
+ }
20
+ const value = exact ?? next?.ms
21
+ if (value === undefined) throw new Error('Local deadline does not exist on this date')
22
+ return value
23
+ }
24
+ /** Latest completed service date whose following-day deadline has passed. */
25
+ export function dailyFeedExpectation({ timeZone, deadline }, now = Date.now()) {
26
+ if (typeof timeZone !== 'string' || timeZone.length > 80 || !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(deadline)) throw new Error('Invalid daily expectation')
27
+ readOperationalTime(new Date(now).toISOString())
28
+ const format = formatter(timeZone), today = wall(format, now).slice(0, 10)
29
+ const todayDeadline = deadlineAt(today, deadline, format), dueToday = now >= todayDeadline
30
+ const dueDate = dueToday ? today : shift(today, -1)
31
+ return { serviceDate: shift(dueDate, -1), deadlineAt: new Date(dueToday ? todayDeadline : deadlineAt(dueDate, deadline, format)).toISOString(),
32
+ nextDeadlineAt: new Date(dueToday ? deadlineAt(shift(today, 1), deadline, format) : todayDeadline).toISOString() }
33
+ }
@@ -0,0 +1,44 @@
1
+ import type { FeedHealth, OperationalState } from './feed-observability.mjs'
2
+ export interface IncidentPolicy { openAfterSeconds: number; recoverAfterSeconds: number; retentionDays: number; maxEvents: number }
3
+ export declare const INCIDENT_POLICY: Readonly<IncidentPolicy>
4
+ export interface IncidentEvent {
5
+ id: string
6
+ incidentId: string
7
+ key: string
8
+ feedId: string | null
9
+ stageId: string
10
+ firstDetectedAt: string
11
+ at: string
12
+ transition: 'opened' | 'updated' | 'recovered'
13
+ state: OperationalState
14
+ reasons: string[]
15
+ }
16
+ export interface ActiveIncident {
17
+ incidentId: string
18
+ key: string
19
+ feedId: string | null
20
+ stageId: string
21
+ firstDetectedAt: string
22
+ openedAt: string
23
+ lastObservedAt: string
24
+ state: OperationalState
25
+ reasons: string[]
26
+ recoveryStartedAt?: string
27
+ recoveryProducerAt?: string
28
+ }
29
+ export interface IncidentState {
30
+ schemaVersion: 1
31
+ kind: 'feed-incident-state'
32
+ registryId: string
33
+ producerId: string
34
+ policy: IncidentPolicy
35
+ lastObservedAt: string
36
+ lastReportId: string
37
+ report: FeedHealth
38
+ active: Record<string, ActiveIncident>
39
+ pending: Record<string, { since: string }>
40
+ events: IncidentEvent[]
41
+ prunedEvents: number
42
+ }
43
+ export function readIncidentStore(root: string): Promise<IncidentState>
44
+ export function updateIncidentStore(root: string, registry: unknown, report: unknown, options?: { policy?: Partial<IncidentPolicy>; now?: number }): Promise<{ state: IncidentState; changed: boolean; events: IncidentEvent[] }>
@@ -0,0 +1,148 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { mkdir, lstat, open, rename, rm } from 'node:fs/promises'
3
+ import { join, resolve } from 'node:path'
4
+ import { hostname } from 'node:os'
5
+ import { readFeedRegistry, readFeedHealth, readOperationalTime } from './feed-observability.mjs'
6
+ import { readOperationalFile } from './operational-files.mjs'
7
+
8
+ const MAX_BYTES = 8 * 1024 * 1024
9
+ export const INCIDENT_POLICY = Object.freeze({ openAfterSeconds: 120, recoverAfterSeconds: 60, retentionDays: 180, maxEvents: 10000 })
10
+ const canonical = value => JSON.stringify(value, (_, v) => v && typeof v === 'object' && !Array.isArray(v) ? Object.fromEntries(Object.keys(v).sort().map(k => [k, v[k]])) : v)
11
+ const hash = value => createHash('sha256').update(value).digest('hex')
12
+ const notInstrumented = new Set(['upload-evidence-unavailable', 'stage-evidence-unavailable', 'capture-threshold-unconfigured', 'normalization-threshold-unconfigured', 'analytics-threshold-unconfigured'])
13
+ function policy(value) {
14
+ const p = { ...INCIDENT_POLICY, ...value }
15
+ if (Object.keys(p).some(k => !Object.hasOwn(INCIDENT_POLICY, k)) || Object.values(p).some(n => !Number.isSafeInteger(n) || n < 0) || p.openAfterSeconds > 86400 || p.recoverAfterSeconds > 86400 || p.retentionDays < 1 || p.retentionDays > 3650 || p.maxEvents < 1 || p.maxEvents > 20000) throw new Error('Invalid incident policy')
16
+ return p
17
+ }
18
+ function validateState(state) {
19
+ if (!state || state.schemaVersion !== 1 || state.kind !== 'feed-incident-state' || typeof state.registryId !== 'string' || typeof state.producerId !== 'string') throw new Error('Invalid incident state')
20
+ policy(state.policy)
21
+ readOperationalTime(state.lastObservedAt)
22
+ if (!/^[a-f0-9]{64}$/.test(state.lastReportId) || !Array.isArray(state.events) || state.events.length > state.policy.maxEvents) throw new Error('Invalid incident cursor/history')
23
+ for (const field of ['active', 'pending']) if (!state[field] || typeof state[field] !== 'object' || Array.isArray(state[field]) || Object.keys(state[field]).length > 4096) throw new Error('Invalid incident index')
24
+ if (!Number.isSafeInteger(state.prunedEvents) || state.prunedEvents < 0) throw new Error('Invalid retention counter')
25
+ for (const event of state.events) {
26
+ if (!/^[a-f0-9]{64}$/.test(event.id) || !/^[a-f0-9]{64}$/.test(event.incidentId) || !['opened', 'updated', 'recovered'].includes(event.transition)) throw new Error('Invalid incident event')
27
+ readOperationalTime(event.at)
28
+ }
29
+ if (new Set(state.events.map(e => e.id)).size !== state.events.length) throw new Error('Duplicate incident event')
30
+ return state
31
+ }
32
+ /** Reads the last atomically committed state; corruption never initializes a new history. */
33
+ export async function readIncidentStore(root) {
34
+ const wrapper = JSON.parse(await readOperationalFile(join(root, 'state.json'), MAX_BYTES))
35
+ if (wrapper.sha256 !== hash(canonical(wrapper.state))) throw new Error('Incident state checksum mismatch')
36
+ return validateState(wrapper.state)
37
+ }
38
+ function conditions(report, registry) {
39
+ const entries = [], add = (feedId, stageId, disposition, state, reasons) => {
40
+ entries.push({ key: JSON.stringify([report.producerId, feedId, stageId]), feedId, stageId, disposition, state, reasons: [...reasons].sort() })
41
+ }
42
+ const available = report.telemetry.state === 'current'
43
+ add(null, 'telemetry', available ? 'healthy' : 'problem', available ? 'healthy' : 'unknown', available ? [] : [`telemetry-${report.telemetry.state}`])
44
+ add(null, 'capacity', !available ? 'hold' : report.capacity.state === 'healthy' ? 'healthy' : 'problem', report.capacity.state, report.capacity.reasons)
45
+ for (const feed of report.feeds) {
46
+ const definition = registry.feeds.find(f => f.id === feed.feedId)
47
+ const actionable = stage => stage.state === 'degraded' || stage.state === 'unknown' && !stage.reasons.some(r => notInstrumented.has(r))
48
+ const blocked = id => definition.stages.find(s => s.id === id).dependsOn.some(parent => actionable(feed.stages.find(s => s.stageId === parent)) || blocked(parent))
49
+ for (const stage of feed.stages) {
50
+ let disposition = !available || feed.configuredState !== 'active' ? 'hold' : stage.state === 'healthy' ? 'healthy' : actionable(stage) ? 'problem' : 'hold'
51
+ if (disposition === 'problem' && (blocked(stage.stageId) || stage.reasons.includes('host-storage-blocked'))) disposition = 'hold'
52
+ add(feed.feedId, stage.stageId, disposition, stage.state, stage.reasons)
53
+ }
54
+ }
55
+ return entries
56
+ }
57
+ function advance(previous, report, registry, p) {
58
+ const at = report.observedAt, now = Date.parse(at), reportId = hash(canonical(report))
59
+ if (previous) {
60
+ if (previous.registryId !== registry.id || previous.producerId !== report.producerId || canonical(previous.policy) !== canonical(p)) throw new Error('Store identity/policy mismatch')
61
+ const order = now - Date.parse(previous.lastObservedAt)
62
+ if (order < 0 || order === 0 && previous.lastReportId !== reportId) throw new Error('Out-of-order or conflicting observation')
63
+ if (order === 0) return { state: previous, changed: false, events: [] }
64
+ }
65
+ const state = previous ? structuredClone(previous) : { schemaVersion: 1, kind: 'feed-incident-state', registryId: registry.id, producerId: report.producerId, policy: p, active: {}, pending: {}, events: [], prunedEvents: 0 }
66
+ const transitions = []
67
+ const emit = (incident, transition, health, reasons) => {
68
+ const event = { incidentId: incident.incidentId, key: incident.key, feedId: incident.feedId, stageId: incident.stageId, firstDetectedAt: incident.firstDetectedAt, at, transition, state: health, reasons }
69
+ transitions.push({ id: hash(canonical(event)), ...event })
70
+ }
71
+ const observedKeys = new Set()
72
+ for (const condition of conditions(report, registry)) {
73
+ const { key, disposition, state: health, reasons, feedId, stageId } = condition
74
+ observedKeys.add(key)
75
+ let incident = state.active[key]
76
+ if (disposition === 'problem') {
77
+ if (!incident) {
78
+ const candidate = state.pending[key] ?? { since: at }
79
+ state.pending[key] = candidate
80
+ if (now - Date.parse(candidate.since) < p.openAfterSeconds * 1000) continue
81
+ incident = { key, feedId, stageId, incidentId: hash(canonical([key, candidate.since])), firstDetectedAt: candidate.since, openedAt: at, lastObservedAt: at, state: health, reasons }
82
+ state.active[key] = incident; delete state.pending[key]
83
+ emit(incident, 'opened', health, reasons)
84
+ } else {
85
+ if (incident.state !== health || canonical(incident.reasons) !== canonical(reasons)) emit(incident, 'updated', health, reasons)
86
+ incident.state = health; incident.reasons = reasons; incident.lastObservedAt = at
87
+ }
88
+ delete incident.recoveryStartedAt; delete incident.recoveryProducerAt
89
+ } else {
90
+ delete state.pending[key]
91
+ if (!incident) continue
92
+ if (disposition === 'hold') { delete incident.recoveryStartedAt; delete incident.recoveryProducerAt; continue }
93
+ incident.recoveryStartedAt ??= at
94
+ incident.recoveryProducerAt ??= report.producerGeneratedAt
95
+ // Fresh observer timestamps alone cannot make a replayed producer report confirm recovery.
96
+ const fresh = p.recoverAfterSeconds === 0 || Date.parse(report.producerGeneratedAt) > Date.parse(incident.recoveryProducerAt)
97
+ if (fresh && now - Date.parse(incident.recoveryStartedAt) >= p.recoverAfterSeconds * 1000) {
98
+ emit(incident, 'recovered', 'healthy', reasons)
99
+ delete state.active[key]
100
+ }
101
+ }
102
+ }
103
+ // Removed/paused scopes cannot silently recover existing incidents.
104
+ for (const key of Object.keys(state.pending)) if (!observedKeys.has(key)) delete state.pending[key]
105
+ for (const [key, incident] of Object.entries(state.active)) if (!observedKeys.has(key)) { delete incident.recoveryStartedAt; delete incident.recoveryProducerAt }
106
+ state.events.push(...transitions)
107
+ const cutoff = now - p.retentionDays * 86400000
108
+ const expired = new Set(state.events.filter(e => e.transition === 'recovered' && Date.parse(e.at) < cutoff).map(e => e.incidentId))
109
+ for (const incident of Object.values(state.active)) expired.delete(incident.incidentId)
110
+ const before = state.events.length
111
+ state.events = state.events.filter(e => !expired.has(e.incidentId))
112
+ state.prunedEvents += before - state.events.length
113
+ state.lastObservedAt = at; state.lastReportId = reportId; state.report = report
114
+ if (state.events.length > p.maxEvents) throw new Error('Incident history event budget exhausted; archive before continuing')
115
+ return { state: validateState(state), changed: true, events: transitions }
116
+ }
117
+
118
+ /** Single-writer transaction: checksum + fsync + atomic rename. A leftover lock fails closed. */
119
+ export async function updateIncidentStore(root, registryValue, reportValue, { policy: requestedPolicy, now = Date.now() } = {}) {
120
+ const registry = readFeedRegistry(registryValue), report = readFeedHealth(reportValue, registry), p = policy(requestedPolicy)
121
+ if (!Number.isFinite(now) || now - Date.parse(report.observedAt) > registry.heartbeatMaxAgeSeconds * 1000 || Date.parse(report.observedAt) - now > registry.clockSkewSeconds * 1000) throw new Error('Incident observation is not current')
122
+ root = resolve(root)
123
+ // Serialize directory creation as well as updates: a second initializer must not
124
+ // take the writer lock before the directory creator has committed its first state.
125
+ const lockPath = `${root}.lock`, lock = await open(lockPath, 'wx', 0o600)
126
+ const temporary = join(root, `.state-${randomUUID()}`)
127
+ try {
128
+ await lock.writeFile(JSON.stringify({ pid: process.pid, host: hostname(), startedAt: new Date(now).toISOString() }))
129
+ await lock.sync()
130
+ let created = false
131
+ try { await mkdir(root, { mode: 0o700 }); created = true } catch (error) { if (error.code !== 'EEXIST') throw error }
132
+ if (!(await lstat(root)).isDirectory()) throw new Error('Incident store must be a real directory')
133
+ const previous = created ? null : await readIncidentStore(root)
134
+ const update = advance(previous, report, registry, p)
135
+ if (!update.changed) return update
136
+ const content = canonical({ sha256: hash(canonical(update.state)), state: update.state }) + '\n'
137
+ if (Buffer.byteLength(content) > MAX_BYTES) throw new Error('Incident history byte budget exhausted; archive before continuing')
138
+ const output = await open(temporary, 'wx', 0o600)
139
+ try { await output.writeFile(content); await output.sync() } finally { await output.close() }
140
+ await rename(temporary, join(root, 'state.json'))
141
+ const directory = await open(root, 'r')
142
+ try { await directory.sync() } finally { await directory.close() }
143
+ return update
144
+ } finally {
145
+ await lock.close()
146
+ try { await rm(temporary, { force: true }) } finally { await rm(lockPath) }
147
+ }
148
+ }
@@ -0,0 +1,68 @@
1
+ export type OperationalState = 'healthy' | 'degraded' | 'unknown' | 'waiting'
2
+ export type ConfiguredState = 'active' | 'paused' | 'setup-required'
3
+ export type FeedSchedule = { kind: 'interval'; seconds: number } | { kind: 'daily'; timeZone: string; deadline: string } | { kind: 'manual' | 'push' }
4
+ export interface FeedStage {
5
+ id: string
6
+ dependsOn: string[]
7
+ maxAgeSeconds: number | null
8
+ sourceMaxAgeSeconds?: number
9
+ }
10
+ export interface FeedDefinition {
11
+ id: string
12
+ label: string
13
+ producerId: string
14
+ producerFeed: string
15
+ sourceId: string
16
+ owner: string
17
+ runbook: string
18
+ state: ConfiguredState
19
+ schedule: FeedSchedule
20
+ stages: FeedStage[]
21
+ }
22
+ export interface FeedRegistry {
23
+ schemaVersion: 1
24
+ kind: 'feed-registry'
25
+ id: string
26
+ heartbeatMaxAgeSeconds: number
27
+ clockSkewSeconds: number
28
+ feeds: FeedDefinition[]
29
+ }
30
+ export interface FeedEvent {
31
+ schemaVersion: 1
32
+ kind: 'feed-event'
33
+ eventId: string
34
+ feedId: string
35
+ stageId: string
36
+ runId: string
37
+ parentRunId?: string
38
+ producerId: string
39
+ revision: string
40
+ occurredAt: string
41
+ recordedAt: string
42
+ type: 'started' | 'succeeded' | 'failed' | 'heartbeat'
43
+ reasons: string[]
44
+ metrics: Record<string, number>
45
+ }
46
+ export interface StageHealth {
47
+ stageId: string
48
+ state: OperationalState
49
+ reasons: string[]
50
+ timestamps: Partial<Record<'receivedAt' | 'sourceGeneratedAt' | 'latestPeriodEnd' | 'lastSuccessAt' | 'lastAttemptAt', string>>
51
+ metrics: Record<string, number>
52
+ }
53
+ export interface FeedHealth {
54
+ schemaVersion: 1
55
+ kind: 'feed-health'
56
+ registryId: string
57
+ producerId: string
58
+ observedAt: string
59
+ producerGeneratedAt: string | null
60
+ telemetry: { state: 'current' | 'stale' | 'missing' | 'invalid'; ageSeconds: number | null }
61
+ feeds: { feedId: string; configuredState: ConfiguredState; state: OperationalState | Exclude<ConfiguredState, 'active'>; stages: StageHealth[] }[]
62
+ capacity: { state: Exclude<OperationalState, 'waiting'>; reasons: string[]; metrics: Record<string, number> }
63
+ }
64
+ export function readOperationalTime(value: unknown): string
65
+ export function readFeedRegistry(value: unknown): FeedRegistry
66
+ export function readFeedEvent(value: unknown, registry: unknown): FeedEvent
67
+ export function readFeedHealth(value: unknown, registry: unknown): FeedHealth
68
+ export function summarizeFeedStages(stages: readonly Pick<StageHealth, 'state'>[]): OperationalState
@@ -0,0 +1,154 @@
1
+ /** Versioned, JSON-only contracts. No I/O, clock, Node imports or provider calls. */
2
+ const own = (o, k) => Object.hasOwn(o, k)
3
+ function object(value, keys, label) {
4
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`Invalid ${label}`)
5
+ if (Object.keys(value).some(k => !keys.includes(k))) throw new Error(`Unknown ${label} field`)
6
+ return value
7
+ }
8
+ function text(value, label, limit = 160) {
9
+ if (typeof value !== 'string' || !value.trim() || value.length > limit || [...value].some(c => c.charCodeAt(0) < 32)) throw new Error(`Invalid ${label}`)
10
+ return value
11
+ }
12
+ function id(value) {
13
+ if (typeof value !== 'string' || !/^[a-z0-9][a-z0-9._:-]{0,127}$/.test(value)) throw new Error('Invalid identifier')
14
+ return value
15
+ }
16
+ function choice(value, values) {
17
+ if (!values.includes(value)) throw new Error('Invalid enum value')
18
+ return value
19
+ }
20
+ function number(value, max = Number.MAX_SAFE_INTEGER) {
21
+ if (!Number.isFinite(value) || value < 0 || value > max) throw new Error('Invalid nonnegative number')
22
+ return value
23
+ }
24
+ function seconds(value) {
25
+ if (!Number.isInteger(value) || value < 1 || value > 366 * 86400) throw new Error('Invalid duration')
26
+ return value
27
+ }
28
+ function array(value, max, min = 0) {
29
+ if (!Array.isArray(value) || value.length < min || value.length > max) throw new Error('Invalid bounded array')
30
+ return value
31
+ }
32
+ function unique(values) {
33
+ if (new Set(values).size !== values.length) throw new Error('Duplicate identifier')
34
+ return values
35
+ }
36
+ export function readOperationalTime(value) {
37
+ if (typeof value !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{3})?Z$/.test(value)) throw new Error('Invalid UTC timestamp')
38
+ const ms = Date.parse(value)
39
+ if (!Number.isFinite(ms) || new Date(ms).toISOString() !== (value.length === 20 ? value.replace('Z', '.000Z') : value)) throw new Error('Invalid UTC timestamp')
40
+ return new Date(ms).toISOString()
41
+ }
42
+ function header(value, kind, keys) {
43
+ object(value, ['schemaVersion', 'kind', ...keys], kind)
44
+ if (value.schemaVersion !== 1 || value.kind !== kind) throw new Error('Unsupported operational schema')
45
+ }
46
+ function schedule(value) {
47
+ object(value, ['kind', 'seconds', 'timeZone', 'deadline'], 'schedule')
48
+ if (value.kind === 'interval') {
49
+ object(value, ['kind', 'seconds'], 'interval schedule')
50
+ return { kind: value.kind, seconds: seconds(value.seconds) }
51
+ }
52
+ if (value.kind === 'daily') {
53
+ object(value, ['kind', 'timeZone', 'deadline'], 'daily schedule')
54
+ text(value.timeZone, 'timezone', 80)
55
+ new Intl.DateTimeFormat('en', { timeZone: value.timeZone })
56
+ if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(value.deadline)) throw new Error('Invalid daily deadline')
57
+ return { kind: value.kind, timeZone: value.timeZone, deadline: value.deadline }
58
+ }
59
+ object(value, ['kind'], 'manual schedule')
60
+ return { kind: choice(value.kind, ['manual', 'push']) }
61
+ }
62
+ export function readFeedRegistry(value) {
63
+ header(value, 'feed-registry', ['id', 'heartbeatMaxAgeSeconds', 'clockSkewSeconds', 'feeds'])
64
+ const feeds = array(value.feeds, 128, 1).map(f => {
65
+ object(f, ['id', 'label', 'producerId', 'producerFeed', 'sourceId', 'owner', 'runbook', 'state', 'schedule', 'stages'], 'feed')
66
+ const stages = array(f.stages, 16, 1).map(s => {
67
+ object(s, ['id', 'dependsOn', 'maxAgeSeconds', 'sourceMaxAgeSeconds'], 'stage')
68
+ return { id: id(s.id), dependsOn: unique(array(s.dependsOn, 16).map(id)),
69
+ maxAgeSeconds: s.maxAgeSeconds === null ? null : seconds(s.maxAgeSeconds),
70
+ ...(own(s, 'sourceMaxAgeSeconds') ? { sourceMaxAgeSeconds: seconds(s.sourceMaxAgeSeconds) } : {}) }
71
+ })
72
+ unique(stages.map(s => s.id))
73
+ const visiting = new Set(), visited = new Set()
74
+ const visit = stageId => {
75
+ if (visiting.has(stageId)) throw new Error('Cyclic stage dependency')
76
+ if (visited.has(stageId)) return
77
+ const stage = stages.find(s => s.id === stageId)
78
+ if (!stage) throw new Error('Unknown stage dependency')
79
+ visiting.add(stageId); stage.dependsOn.forEach(visit); visiting.delete(stageId); visited.add(stageId)
80
+ }
81
+ stages.forEach(s => visit(s.id))
82
+ return { id: id(f.id), label: text(f.label, 'label'), producerId: id(f.producerId), producerFeed: id(f.producerFeed), sourceId: id(f.sourceId),
83
+ owner: text(f.owner, 'owner'), runbook: text(f.runbook, 'runbook', 300), state: choice(f.state, ['active', 'paused', 'setup-required']), schedule: schedule(f.schedule), stages }
84
+ })
85
+ unique(feeds.map(f => f.id)); unique(feeds.map(f => `${f.producerId}/${f.producerFeed}`))
86
+ return { schemaVersion: 1, kind: 'feed-registry', id: id(value.id), heartbeatMaxAgeSeconds: seconds(value.heartbeatMaxAgeSeconds),
87
+ clockSkewSeconds: number(value.clockSkewSeconds, 300), feeds }
88
+ }
89
+ function metrics(value) {
90
+ object(value, Object.keys(value ?? {}), 'metrics')
91
+ if (Object.keys(value).length > 32) throw new Error('Too many metrics')
92
+ return Object.fromEntries(Object.entries(value).map(([key, v]) => [id(key), number(v)]))
93
+ }
94
+ function reasons(value) { return unique(array(value, 16).map(id)) }
95
+ function registered(registry, feedId, stageId) {
96
+ const feed = registry.feeds.find(f => f.id === feedId)
97
+ if (!feed?.stages.some(s => s.id === stageId)) throw new Error('Unregistered feed or stage')
98
+ }
99
+ export function readFeedEvent(value, registryValue) {
100
+ header(value, 'feed-event', ['eventId', 'feedId', 'stageId', 'runId', 'parentRunId', 'producerId', 'revision', 'occurredAt', 'recordedAt', 'type', 'reasons', 'metrics'])
101
+ const registry = readFeedRegistry(registryValue)
102
+ registered(registry, value.feedId, value.stageId)
103
+ if (registry.feeds.find(f => f.id === value.feedId).producerId !== value.producerId) throw new Error('Producer does not own feed')
104
+ return { schemaVersion: 1, kind: 'feed-event', eventId: id(value.eventId), feedId: id(value.feedId), stageId: id(value.stageId), runId: id(value.runId),
105
+ ...(own(value, 'parentRunId') ? { parentRunId: id(value.parentRunId) } : {}), producerId: id(value.producerId), revision: id(value.revision),
106
+ occurredAt: readOperationalTime(value.occurredAt), recordedAt: readOperationalTime(value.recordedAt),
107
+ type: choice(value.type, ['started', 'succeeded', 'failed', 'heartbeat']), reasons: reasons(value.reasons), metrics: metrics(value.metrics) }
108
+ }
109
+ export function summarizeFeedStages(stages) {
110
+ return ['degraded', 'unknown', 'waiting'].find(state => stages.some(s => s.state === state)) ?? 'healthy'
111
+ }
112
+ export function readFeedHealth(value, registryValue) {
113
+ header(value, 'feed-health', ['registryId', 'producerId', 'observedAt', 'producerGeneratedAt', 'telemetry', 'feeds', 'capacity'])
114
+ const registry = readFeedRegistry(registryValue)
115
+ if (value.registryId !== registry.id) throw new Error('Registry does not match health report')
116
+ const producerId = id(value.producerId), expected = registry.feeds.filter(f => f.producerId === producerId)
117
+ if (!expected.length) throw new Error('Unregistered producer')
118
+ const observedAt = readOperationalTime(value.observedAt)
119
+ const producerGeneratedAt = value.producerGeneratedAt === null ? null : readOperationalTime(value.producerGeneratedAt)
120
+ object(value.telemetry, ['state', 'ageSeconds'], 'telemetry')
121
+ const telemetry = { state: choice(value.telemetry.state, ['current', 'stale', 'missing', 'invalid']), ageSeconds: value.telemetry.ageSeconds === null ? null : number(value.telemetry.ageSeconds) }
122
+ if (producerGeneratedAt === null) {
123
+ if (!['missing', 'invalid'].includes(telemetry.state) || telemetry.ageSeconds !== null) throw new Error('Missing producer clock cannot establish freshness')
124
+ } else {
125
+ const elapsed = (Date.parse(observedAt) - Date.parse(producerGeneratedAt)) / 1000
126
+ const state = elapsed < -registry.clockSkewSeconds ? 'invalid' : elapsed > registry.heartbeatMaxAgeSeconds ? 'stale' : 'current'
127
+ if (telemetry.state !== state || telemetry.ageSeconds === null || Math.abs(telemetry.ageSeconds - Math.max(0, elapsed)) > 0.000001) throw new Error('Telemetry does not match report clocks')
128
+ }
129
+ const feeds = array(value.feeds, 128, 1).map(f => {
130
+ object(f, ['feedId', 'configuredState', 'state', 'stages'], 'feed health')
131
+ const entry = expected.find(e => e.id === f.feedId)
132
+ if (!entry || f.configuredState !== entry.state) throw new Error('Feed configuration does not match registry')
133
+ const stages = array(f.stages, 16, 1).map(s => {
134
+ object(s, ['stageId', 'state', 'reasons', 'timestamps', 'metrics'], 'stage health')
135
+ registered(registry, f.feedId, s.stageId)
136
+ object(s.timestamps, ['receivedAt', 'sourceGeneratedAt', 'latestPeriodEnd', 'lastSuccessAt', 'lastAttemptAt'], 'timestamps')
137
+ return { stageId: id(s.stageId), state: choice(s.state, ['healthy', 'degraded', 'unknown', 'waiting']), reasons: reasons(s.reasons),
138
+ timestamps: Object.fromEntries(Object.entries(s.timestamps).map(([k, v]) => [k, readOperationalTime(v)])), metrics: metrics(s.metrics) }
139
+ })
140
+ unique(stages.map(s => s.stageId))
141
+ if (stages.length !== entry.stages.length) throw new Error('Incomplete stage report')
142
+ const state = entry.state === 'active' ? summarizeFeedStages(stages) : entry.state
143
+ if (f.state !== state) throw new Error('Inconsistent feed summary')
144
+ if (telemetry.state !== 'current' && stages.some(s => s.state !== 'unknown')) throw new Error('Unavailable telemetry cannot assert stage health')
145
+ return { feedId: f.feedId, configuredState: entry.state, state, stages }
146
+ })
147
+ unique(feeds.map(f => f.feedId))
148
+ if (feeds.length !== expected.length) throw new Error('Incomplete producer report')
149
+ object(value.capacity, ['state', 'reasons', 'metrics'], 'capacity')
150
+ const capacity = { state: choice(value.capacity.state, ['healthy', 'degraded', 'unknown']), reasons: reasons(value.capacity.reasons), metrics: metrics(value.capacity.metrics) }
151
+ if (telemetry.state !== 'current' && capacity.state !== 'unknown') throw new Error('Unavailable telemetry cannot assert capacity health')
152
+ return { schemaVersion: 1, kind: 'feed-health', registryId: registry.id, producerId, observedAt,
153
+ producerGeneratedAt, telemetry, feeds, capacity }
154
+ }
@@ -0,0 +1,30 @@
1
+ import type { SERVICE_TIMEZONE } from './uk-service-day.mjs'
2
+ import type { FeedHealth, StageHealth } from './feed-observability.mjs'
3
+ import type { DailyExpectation } from './feed-expectations.mjs'
4
+ export type ObserverCredential = string | { bearer?: string; accessClientId?: string; accessClientSecret?: string }
5
+ export interface ObserverConfig {
6
+ schemaVersion: 1
7
+ kind: 'feed-observer-config'
8
+ producerId: string
9
+ healthUrl: string
10
+ consumers: { feedId: string; manifestUrl: string; operator: string; deadline: string; timeZone: typeof SERVICE_TIMEZONE }[]
11
+ }
12
+ export interface ObserverCheck {
13
+ schemaVersion: 1
14
+ kind: 'feed-observer-check'
15
+ startedAt: string
16
+ completedAt: string
17
+ sourceError: string | null
18
+ requests: number
19
+ bytes: number
20
+ report: FeedHealth
21
+ consumers: (StageHealth & { feedId: string; expectation: DailyExpectation })[]
22
+ }
23
+ export declare const OBSERVER_LIMITS: Readonly<{ requests: number; totalBytes: number; timeoutMs: number; totalMs: number; healthBytes: number; manifestBytes: number; releaseBytes: number }>
24
+ export function observeFeeds(registry: unknown, config: unknown, options?: {
25
+ fetchImpl?: typeof fetch
26
+ clock?: () => number
27
+ tokens?: { health?: ObserverCredential; consumers?: Record<string, ObserverCredential> }
28
+ limits?: typeof OBSERVER_LIMITS
29
+ }): Promise<ObserverCheck>
30
+ export function assessObserverCheck(registry: unknown, checked: ObserverCheck | null | undefined, options?: { now?: number; maxAgeSeconds?: number }): { state: 'healthy' | 'degraded' | 'unknown'; reasons: string[] }