@motionstudies/data 0.1.0-alpha.27 → 0.1.0-alpha.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/feed-expectations.d.mts +3 -0
- package/feed-expectations.mjs +33 -0
- package/feed-incidents.d.mts +44 -0
- package/feed-incidents.mjs +148 -0
- package/feed-observability.d.mts +68 -0
- package/feed-observability.mjs +154 -0
- package/feed-observer.d.mts +30 -0
- package/feed-observer.mjs +192 -0
- package/operational-files.mjs +21 -0
- package/package.json +34 -2
- package/recorder-evidence.d.mts +14 -0
- package/recorder-evidence.mjs +136 -0
- package/recorder-observability.d.mts +3 -0
- package/recorder-observability.mjs +106 -0
- package/release-files.d.mts +14 -0
- package/release-files.mjs +47 -0
- package/release.d.mts +10 -0
- package/release.mjs +62 -0
- package/uk-service-day.d.mts +1 -0
- package/uk-service-day.mjs +3 -3
package/README.md
CHANGED
|
@@ -194,6 +194,14 @@ Consumers supply remaining `stops` in journey order and update them with playbac
|
|
|
194
194
|
|
|
195
195
|
The required `note` explains provenance. Optional `status`, `statusTone`, `clockLabel`, localized `labels`, `loading`, `error` and `onRetry` follow the consumer's data; the component never manufactures an on-time status. Loading and errors hide the calling points. Stop selection is controlled through stable call IDs and does not alter playback or clear when a call leaves the list. All supplied calls are shown, with wrapping names and container-based compact layouts. The lab exercises advance/rewind, unknown destinations/times, localization, disruption, recovery and narrow widths. Edition adoption requires its own package upgrade and data adapter.
|
|
196
196
|
|
|
197
|
+
## Feed observability
|
|
198
|
+
|
|
199
|
+
`@motionstudies/data/feed-observability` validates versioned feed registries, operational events and health reports. `@motionstudies/data/recorder-observability` projects a recorder status snapshot into that shared contract, rechecking freshness against the observer's clock and omitting private paths, PIDs and raw errors. Both are pure functions with no acquisition, storage or notification side effects. The source repository's `docs/FEED-OBSERVABILITY.md` describes the read-only CLI, example registry, states and evidence limits. Registry daily schedules remain declarative; explicit dated evidence plans and incident history are available through the Node adapters below.
|
|
200
|
+
|
|
201
|
+
`@motionstudies/data/recorder-evidence` reads explicit dated local journal/close-out and release targets, verifies their hashes and identities, and assesses missing expected outputs against deadlines. `@motionstudies/data/feed-incidents` persists debounced incident transitions with replay protection, atomic checksummed state, bounded retention and fail-closed capacity limits. These two modules require Node. See `docs/FEED-INCIDENTS.md` for CLI opt-in flags and operational limits; neither module installs a scheduler or sends notifications.
|
|
202
|
+
|
|
203
|
+
`@motionstudies/data/release` checks versioned release handoffs in Node or a browser: safe file descriptors (`{ path, sha256, bytes }`), every descriptor in a manifest whatever its layout, kind and schema, and fetched bytes through Web Crypto. `@motionstudies/data/release-files` opens a release directory in Node, optionally pinned to its manifest digest, reads only described files without escaping the directory, and verifies a whole release within a byte budget. Format meaning stays with each format's reader. See the source repository's `docs/RELEASE-CONTRACT.md`.
|
|
204
|
+
|
|
197
205
|
## Optional live airport feed
|
|
198
206
|
|
|
199
207
|
`AirportBoard` from `@motionstudies/web/components/AirportBoard` adds Study/Now controls around an existing `AirportHeroCard` configuration. Pass `studyCard` with the usual card props and `live={{ baseUrl, edition, airport }}` for the shared service. Import `airport-hero-card.css`. `labels` localizes the wrapper's control and availability messages. The lower-level `useAirportFeed` hook and core `domain/live-airport` contract are also public exports.
|
|
@@ -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[] }
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { readFeedRegistry, readFeedHealth, readOperationalTime, summarizeFeedStages } from './feed-observability.mjs'
|
|
2
|
+
import { recorderFeedHealth } from './recorder-observability.mjs'
|
|
3
|
+
import { dailyFeedExpectation } from './feed-expectations.mjs'
|
|
4
|
+
import { studyDay, SERVICE_TIMEZONE } from './uk-service-day.mjs'
|
|
5
|
+
|
|
6
|
+
export const OBSERVER_LIMITS = Object.freeze({ requests: 9, totalBytes: 10 * 1024 * 1024, timeoutMs: 5000, totalMs: 30000, healthBytes: 1024 * 1024, manifestBytes: 8192, releaseBytes: 2 * 1024 * 1024 })
|
|
7
|
+
const digest = s => typeof s === 'string' && /^[a-f0-9]{64}$/.test(s)
|
|
8
|
+
const sha = async bytes => [...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))].map(b => b.toString(16).padStart(2, '0')).join('')
|
|
9
|
+
const stage = (state, reason, timestamps = {}, metrics = {}) => ({ stageId: 'serve', state, reasons: [reason], timestamps, metrics })
|
|
10
|
+
function checkUrl(value) {
|
|
11
|
+
const url = new URL(value)
|
|
12
|
+
if (url.username || url.password || url.hash || url.search || !['https:', 'http:'].includes(url.protocol) || url.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) throw new Error('Use HTTPS or loopback HTTP without embedded credentials or query strings')
|
|
13
|
+
return url.href
|
|
14
|
+
}
|
|
15
|
+
function config(value, registry) {
|
|
16
|
+
if (value?.schemaVersion !== 1 || value.kind !== 'feed-observer-config' || Object.keys(value).some(k => !['schemaVersion', 'kind', 'producerId', 'healthUrl', 'consumers'].includes(k))) throw new Error('Invalid observer config')
|
|
17
|
+
if (!registry.feeds.some(f => f.producerId === value.producerId) || !Array.isArray(value.consumers) || value.consumers.length > 4) throw new Error('Invalid observer targets')
|
|
18
|
+
const consumers = value.consumers.map(c => {
|
|
19
|
+
if (!c || Object.keys(c).some(k => !['feedId', 'manifestUrl', 'operator', 'deadline', 'timeZone'].includes(k))) throw new Error('Invalid consumer config')
|
|
20
|
+
if (!registry.feeds.some(f => f.id === c.feedId && f.producerId === value.producerId && f.stages.some(s => s.id === 'serve'))) throw new Error('Register the consumer serve stage')
|
|
21
|
+
if (typeof c.operator !== 'string' || !c.operator.trim() || c.operator.length > 100 || c.timeZone !== SERVICE_TIMEZONE || !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(c.deadline)) throw new Error('Invalid bus consumer expectation')
|
|
22
|
+
return { ...c, manifestUrl: checkUrl(c.manifestUrl) }
|
|
23
|
+
})
|
|
24
|
+
if (new Set(consumers.map(c => c.feedId)).size !== consumers.length) throw new Error('Duplicate consumer target')
|
|
25
|
+
return { producerId: value.producerId, healthUrl: checkUrl(value.healthUrl), consumers }
|
|
26
|
+
}
|
|
27
|
+
class CheckError extends Error { constructor(reason) { super(reason); this.reason = reason } }
|
|
28
|
+
function client(fetchImpl, tokens, limits) {
|
|
29
|
+
const began = performance.now(), usage = { requests: 0, bytes: 0 }
|
|
30
|
+
const read = async (url, cap, token) => {
|
|
31
|
+
if (usage.requests >= limits.requests || usage.bytes >= limits.totalBytes || performance.now() - began >= limits.totalMs) throw new CheckError('check-budget-exhausted')
|
|
32
|
+
usage.requests++
|
|
33
|
+
const controller = new AbortController()
|
|
34
|
+
let reader, body, timer
|
|
35
|
+
const timeout = new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new CheckError('request-timeout')) }, Math.min(limits.timeoutMs, limits.totalMs - (performance.now() - began))) })
|
|
36
|
+
try {
|
|
37
|
+
return await Promise.race([timeout, (async () => {
|
|
38
|
+
const credential = typeof token === 'string' ? { bearer: token } : token ?? {}
|
|
39
|
+
const headers = { Accept: 'application/json', 'Cache-Control': 'no-cache', ...(credential.bearer ? { Authorization: `Bearer ${credential.bearer}` } : {}) }
|
|
40
|
+
if (credential.accessClientId && credential.accessClientSecret) {
|
|
41
|
+
headers['CF-Access-Client-Id'] = credential.accessClientId
|
|
42
|
+
headers['CF-Access-Client-Secret'] = credential.accessClientSecret
|
|
43
|
+
} else if (credential.accessClientId || credential.accessClientSecret) throw new CheckError('credentials-invalid')
|
|
44
|
+
const response = await fetchImpl(url, { headers, signal: controller.signal, redirect: 'error', cache: 'no-store' })
|
|
45
|
+
body = response.body
|
|
46
|
+
if (!response.ok || response.redirected) throw new CheckError('http-unavailable')
|
|
47
|
+
const advertised = response.headers.get('content-length')
|
|
48
|
+
if (advertised !== null && (!/^\d+$/.test(advertised) || Number(advertised) > cap)) throw new CheckError('response-too-large')
|
|
49
|
+
reader = response.body?.getReader()
|
|
50
|
+
if (!reader) throw new CheckError('document-invalid')
|
|
51
|
+
let size = 0
|
|
52
|
+
const chunks = []
|
|
53
|
+
while (true) {
|
|
54
|
+
const { done, value } = await reader.read()
|
|
55
|
+
if (done) break
|
|
56
|
+
size += value.byteLength; usage.bytes += value.byteLength
|
|
57
|
+
if (size > cap || usage.bytes > limits.totalBytes) throw new CheckError('response-too-large')
|
|
58
|
+
chunks.push(value)
|
|
59
|
+
}
|
|
60
|
+
const bytes = new Uint8Array(size)
|
|
61
|
+
let offset = 0
|
|
62
|
+
for (const c of chunks) { bytes.set(c, offset); offset += c.length }
|
|
63
|
+
let json
|
|
64
|
+
try { json = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) } catch { throw new CheckError('document-invalid') }
|
|
65
|
+
return { bytes, json }
|
|
66
|
+
})()])
|
|
67
|
+
} catch (e) { throw e instanceof CheckError ? e : new CheckError('request-failed') }
|
|
68
|
+
finally { clearTimeout(timer); controller.abort(); if (reader) void reader.cancel().catch(() => {}); else if (body) void body.cancel().catch(() => {}) }
|
|
69
|
+
}
|
|
70
|
+
return { usage, health: url => read(url, limits.healthBytes, tokens.health), consumer: (url, kind, token) => read(url, limits[`${kind}Bytes`], token) }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Reevaluate a mirrored producer report at the checker's clock; never renew its heartbeat. */
|
|
74
|
+
function refresh(registry, raw, producerId, now) {
|
|
75
|
+
const report = readFeedHealth(raw, registry)
|
|
76
|
+
if (report.producerId !== producerId || Date.parse(report.observedAt) > now + registry.clockSkewSeconds * 1000) throw new Error('Unexpected producer/observer clock')
|
|
77
|
+
const generated = report.producerGeneratedAt ? Date.parse(report.producerGeneratedAt) : null
|
|
78
|
+
const elapsed = generated === null ? null : (now - generated) / 1000
|
|
79
|
+
report.observedAt = new Date(now).toISOString()
|
|
80
|
+
report.telemetry = { state: elapsed === null ? report.telemetry.state : elapsed < -registry.clockSkewSeconds ? 'invalid' : elapsed > registry.heartbeatMaxAgeSeconds ? 'stale' : 'current', ageSeconds: elapsed === null ? null : Math.max(0, elapsed) }
|
|
81
|
+
for (const f of report.feeds) {
|
|
82
|
+
const entry = registry.feeds.find(e => e.id === f.feedId)
|
|
83
|
+
f.stages = f.stages.map(s => {
|
|
84
|
+
if (report.telemetry.state !== 'current') return { ...s, state: 'unknown', reasons: [`telemetry-${report.telemetry.state}`], timestamps: {}, metrics: {} }
|
|
85
|
+
const def = entry.stages.find(d => d.id === s.stageId)
|
|
86
|
+
const field = { capture: 'receivedAt', normalize: 'latestPeriodEnd', analytics: 'lastAttemptAt' }[s.stageId]
|
|
87
|
+
const metric = { capture: 'capture-age-seconds', normalize: 'normalization-lag-seconds', analytics: 'analytics-status-age-seconds' }[s.stageId]
|
|
88
|
+
if (field && s.timestamps[field]) s.metrics[metric] = Math.max(0, (now - Date.parse(s.timestamps[field])) / 1000)
|
|
89
|
+
if (s.stageId === 'capture' && s.timestamps.sourceGeneratedAt) s.metrics['source-age-seconds'] = Math.max(0, (now - Date.parse(s.timestamps.sourceGeneratedAt)) / 1000)
|
|
90
|
+
if (f.configuredState !== 'active' || !['healthy', 'waiting'].includes(s.state)) return s
|
|
91
|
+
if (field && def.maxAgeSeconds !== null) {
|
|
92
|
+
const stamp = s.timestamps[field], age = stamp ? (now - Date.parse(stamp)) / 1000 : null
|
|
93
|
+
if (age === null) return { ...s, state: 'unknown', reasons: ['stage-clock-unavailable'] }
|
|
94
|
+
if (age < -registry.clockSkewSeconds) return { ...s, state: 'unknown', reasons: ['stage-clock-invalid'] }
|
|
95
|
+
if (age > def.maxAgeSeconds) return { ...s, state: s.stageId === 'analytics' ? 'unknown' : 'degraded', reasons: [`${s.stageId}-stale`] }
|
|
96
|
+
}
|
|
97
|
+
if (s.stageId === 'capture' && def.sourceMaxAgeSeconds) {
|
|
98
|
+
const stamp = s.timestamps.sourceGeneratedAt
|
|
99
|
+
if (!stamp) return { ...s, state: 'unknown', reasons: ['source-time-unavailable'] }
|
|
100
|
+
if ((now - Date.parse(stamp)) / 1000 < -registry.clockSkewSeconds) return { ...s, state: 'unknown', reasons: ['source-clock-invalid'] }
|
|
101
|
+
if ((now - Date.parse(stamp)) / 1000 > def.sourceMaxAgeSeconds) return { ...s, state: 'degraded', reasons: ['source-stale'] }
|
|
102
|
+
}
|
|
103
|
+
return s
|
|
104
|
+
})
|
|
105
|
+
f.state = f.configuredState === 'active' ? summarizeFeedStages(f.stages) : f.configuredState
|
|
106
|
+
}
|
|
107
|
+
if (report.telemetry.state !== 'current') report.capacity = { state: 'unknown', reasons: [`telemetry-${report.telemetry.state}`], metrics: {} }
|
|
108
|
+
return readFeedHealth(report, registry)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function consumer(c, expectation, api, now, skew, token) {
|
|
112
|
+
try {
|
|
113
|
+
const { json: manifest } = await api.consumer(c.manifestUrl, 'manifest', token)
|
|
114
|
+
const id = manifest.resultId, file = manifest.report
|
|
115
|
+
if (manifest.schemaVersion !== 1 || !digest(id) || file?.path !== `releases/${id}/result.json` || !Number.isSafeInteger(file.bytes) || file.bytes < 1 || file.bytes > OBSERVER_LIMITS.releaseBytes || !digest(file.sha256)) throw new CheckError('manifest-invalid')
|
|
116
|
+
const publishedAt = readOperationalTime(manifest.observedAt)
|
|
117
|
+
if (Date.parse(publishedAt) > now + skew * 1000) throw new CheckError('publication-clock-invalid')
|
|
118
|
+
const { json: release, bytes } = await api.consumer(new URL(file.path, c.manifestUrl).href, 'release', token)
|
|
119
|
+
const { resultId, ...payload } = release
|
|
120
|
+
if (bytes.length !== file.bytes || await sha(bytes) !== file.sha256 || resultId !== id || await sha(new TextEncoder().encode(JSON.stringify(payload))) !== id) throw new CheckError('release-integrity-failed')
|
|
121
|
+
if (release.schemaVersion !== 2 || release.kind !== 'feed-quality-history' || release.operator !== c.operator || !Array.isArray(release.days) || release.days.length > 366) throw new CheckError('release-invalid')
|
|
122
|
+
readOperationalTime(`${release.from}T00:00:00Z`); readOperationalTime(`${release.to}T00:00:00Z`)
|
|
123
|
+
if (release.from > release.to) throw new CheckError('release-invalid')
|
|
124
|
+
const expected = studyDay(expectation.serviceDate)
|
|
125
|
+
const selected = release.days.filter(d => d.date === expectation.serviceDate)
|
|
126
|
+
if (release.from > expectation.serviceDate || release.to < expectation.serviceDate || selected.length === 0) return stage('degraded', 'consumer-period-overdue')
|
|
127
|
+
if (selected.length !== 1) throw new CheckError('release-invalid')
|
|
128
|
+
const day = selected[0]
|
|
129
|
+
if (day.status !== 'current') return stage('degraded', 'consumer-day-overdue')
|
|
130
|
+
if (!digest(day.revision) || !day.quality || day.day?.startUtc !== expected.startUtc || day.day?.endUtc !== expected.endUtc || day.day?.serviceDate !== expected.serviceDate || Date.parse(publishedAt) < Date.parse(expected.endUtc)) throw new CheckError('release-day-invalid')
|
|
131
|
+
return stage('healthy', 'consumer-release-verified', { lastSuccessAt: publishedAt, latestPeriodEnd: expected.endUtc }, { 'verified-release-bytes': bytes.length })
|
|
132
|
+
} catch (e) { return stage('degraded', `consumer-${e instanceof CheckError ? e.reason : 'document-invalid'}`) }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Portable Web-API checker; reads configured endpoints only, with no redirects or retries. */
|
|
136
|
+
export async function observeFeeds(registryValue, configValue, { fetchImpl = fetch, clock = Date.now, tokens = {}, limits = OBSERVER_LIMITS } = {}) {
|
|
137
|
+
const registry = readFeedRegistry(registryValue), c = config(configValue, registry)
|
|
138
|
+
// Tests may lower bounds, never raise them.
|
|
139
|
+
for (const [key, value] of Object.entries(OBSERVER_LIMITS)) if (!Number.isSafeInteger(limits[key]) || limits[key] < 1 || limits[key] > value) throw new Error('Invalid check limit')
|
|
140
|
+
const api = client(fetchImpl, tokens, limits), startedAt = new Date(clock()).toISOString()
|
|
141
|
+
let source = null, sourceError = null
|
|
142
|
+
try { source = (await api.health(c.healthUrl)).json; refresh(registry, source, c.producerId, clock()) }
|
|
143
|
+
catch (e) { source = null; sourceError = e instanceof CheckError ? e.reason : 'document-invalid' }
|
|
144
|
+
const checks = []
|
|
145
|
+
for (const target of c.consumers) {
|
|
146
|
+
const definition = registry.feeds.find(f => f.id === target.feedId)
|
|
147
|
+
if (definition.state !== 'active') continue
|
|
148
|
+
const expectation = dailyFeedExpectation(target, clock())
|
|
149
|
+
const health = await consumer(target, expectation, api, clock(), registry.clockSkewSeconds, tokens.consumers?.[target.feedId])
|
|
150
|
+
checks.push({ feedId: target.feedId, expectation, ...health })
|
|
151
|
+
}
|
|
152
|
+
const finished = clock()
|
|
153
|
+
for (const check of checks) {
|
|
154
|
+
const target = c.consumers.find(t => t.feedId === check.feedId), latest = dailyFeedExpectation(target, finished)
|
|
155
|
+
if (latest.serviceDate !== check.expectation.serviceDate) Object.assign(check, stage('degraded', 'consumer-expectation-advanced'), { expectation: latest })
|
|
156
|
+
}
|
|
157
|
+
let report
|
|
158
|
+
if (source) report = refresh(registry, source, c.producerId, finished)
|
|
159
|
+
else report = recorderFeedHealth(registry, sourceError === 'document-invalid' ? {} : null, { producerId: c.producerId, now: finished })
|
|
160
|
+
if (report.telemetry.state === 'current') for (const check of checks) {
|
|
161
|
+
const feed = report.feeds.find(f => f.feedId === check.feedId)
|
|
162
|
+
const { feedId: _feedId, expectation: _expectation, ...health } = check
|
|
163
|
+
feed.stages = feed.stages.map(s => s.stageId === 'serve' ? health : s)
|
|
164
|
+
feed.state = summarizeFeedStages(feed.stages)
|
|
165
|
+
}
|
|
166
|
+
return { schemaVersion: 1, kind: 'feed-observer-check', startedAt, completedAt: new Date(finished).toISOString(), sourceError, requests: api.usage.requests, bytes: api.usage.bytes,
|
|
167
|
+
report: readFeedHealth(report, registry), consumers: checks }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Assess a stored check at read time. A successful old check is historical evidence. */
|
|
171
|
+
export function assessObserverCheck(registryValue, checked, { now = Date.now(), maxAgeSeconds = 180 } = {}) {
|
|
172
|
+
const registry = readFeedRegistry(registryValue)
|
|
173
|
+
if (!Number.isFinite(now) || !Number.isSafeInteger(maxAgeSeconds) || maxAgeSeconds < 1 || maxAgeSeconds > 3600) throw new Error('Invalid observer clock/threshold')
|
|
174
|
+
const unknown = reason => ({ state: 'unknown', reasons: [reason] })
|
|
175
|
+
if (!checked) return unknown('checker-missing')
|
|
176
|
+
try {
|
|
177
|
+
if (checked.schemaVersion !== 1 || checked.kind !== 'feed-observer-check' || !Array.isArray(checked.consumers)) return unknown('checker-invalid')
|
|
178
|
+
const completed = Date.parse(readOperationalTime(checked.completedAt)), started = Date.parse(readOperationalTime(checked.startedAt))
|
|
179
|
+
if (started > completed || completed > now + registry.clockSkewSeconds * 1000) return unknown('checker-clock-invalid')
|
|
180
|
+
if (now - completed > maxAgeSeconds * 1000) return unknown('checker-stale')
|
|
181
|
+
const report = refresh(registry, checked.report, checked.report.producerId, now)
|
|
182
|
+
if (report.telemetry.state !== 'current') return unknown(`telemetry-${report.telemetry.state}`)
|
|
183
|
+
for (const c of checked.consumers) {
|
|
184
|
+
if (Date.parse(readOperationalTime(c.expectation.nextDeadlineAt)) <= now) return unknown('consumer-expectation-expired')
|
|
185
|
+
if (!['healthy', 'degraded', 'unknown', 'waiting'].includes(c.state)) return unknown('checker-invalid')
|
|
186
|
+
}
|
|
187
|
+
const states = [report.capacity.state, ...report.feeds.filter(f => f.configuredState === 'active').map(f => f.state), ...checked.consumers.map(c => c.state)]
|
|
188
|
+
if (states.includes('degraded')) return { state: 'degraded', reasons: ['feed-attention-required'] }
|
|
189
|
+
if (states.includes('unknown')) return unknown('feed-evidence-unavailable')
|
|
190
|
+
return { state: 'healthy', reasons: ['check-current'] }
|
|
191
|
+
} catch { return unknown('checker-invalid') }
|
|
192
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { open, constants } from 'node:fs/promises'
|
|
2
|
+
|
|
3
|
+
/** Read a bounded regular file without following a final symlink or blocking on a FIFO. */
|
|
4
|
+
export async function readOperationalFile(path, limit) {
|
|
5
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
|
6
|
+
try {
|
|
7
|
+
const stat = await handle.stat()
|
|
8
|
+
if (!stat.isFile() || stat.size > limit) throw new Error('Input must be a bounded regular file')
|
|
9
|
+
// Size the buffer from the file, not the limit, so a generous limit costs nothing for small inputs; one spare byte detects growth.
|
|
10
|
+
const bytes = Buffer.alloc(Math.min(limit, stat.size) + 1)
|
|
11
|
+
let size = 0
|
|
12
|
+
while (size <= limit) {
|
|
13
|
+
const { bytesRead } = await handle.read(bytes, size, bytes.length - size, null)
|
|
14
|
+
if (!bytesRead) break
|
|
15
|
+
size += bytesRead
|
|
16
|
+
}
|
|
17
|
+
const after = await handle.stat()
|
|
18
|
+
if (size > limit || size !== stat.size || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs) throw new Error('Input changed or exceeds read limit')
|
|
19
|
+
return bytes.subarray(0, size)
|
|
20
|
+
} finally { await handle.close() }
|
|
21
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@motionstudies/data",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.29",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Offline data preparation primitives for Motion Studies.",
|
|
@@ -59,6 +59,14 @@
|
|
|
59
59
|
"types": "./source-store.d.mts",
|
|
60
60
|
"import": "./source-store.mjs"
|
|
61
61
|
},
|
|
62
|
+
"./release": {
|
|
63
|
+
"types": "./release.d.mts",
|
|
64
|
+
"import": "./release.mjs"
|
|
65
|
+
},
|
|
66
|
+
"./release-files": {
|
|
67
|
+
"types": "./release-files.d.mts",
|
|
68
|
+
"import": "./release-files.mjs"
|
|
69
|
+
},
|
|
62
70
|
"./uk-service-day": {
|
|
63
71
|
"types": "./uk-service-day.d.mts",
|
|
64
72
|
"import": "./uk-service-day.mjs"
|
|
@@ -86,6 +94,30 @@
|
|
|
86
94
|
"./rail-geometry": {
|
|
87
95
|
"types": "./rail-geometry.d.mts",
|
|
88
96
|
"import": "./rail-geometry.mjs"
|
|
97
|
+
},
|
|
98
|
+
"./feed-observability": {
|
|
99
|
+
"types": "./feed-observability.d.mts",
|
|
100
|
+
"import": "./feed-observability.mjs"
|
|
101
|
+
},
|
|
102
|
+
"./recorder-observability": {
|
|
103
|
+
"types": "./recorder-observability.d.mts",
|
|
104
|
+
"import": "./recorder-observability.mjs"
|
|
105
|
+
},
|
|
106
|
+
"./recorder-evidence": {
|
|
107
|
+
"types": "./recorder-evidence.d.mts",
|
|
108
|
+
"import": "./recorder-evidence.mjs"
|
|
109
|
+
},
|
|
110
|
+
"./feed-incidents": {
|
|
111
|
+
"types": "./feed-incidents.d.mts",
|
|
112
|
+
"import": "./feed-incidents.mjs"
|
|
113
|
+
},
|
|
114
|
+
"./feed-observer": {
|
|
115
|
+
"types": "./feed-observer.d.mts",
|
|
116
|
+
"import": "./feed-observer.mjs"
|
|
117
|
+
},
|
|
118
|
+
"./feed-expectations": {
|
|
119
|
+
"types": "./feed-expectations.d.mts",
|
|
120
|
+
"import": "./feed-expectations.mjs"
|
|
89
121
|
}
|
|
90
122
|
},
|
|
91
123
|
"files": [
|
|
@@ -101,7 +133,7 @@
|
|
|
101
133
|
],
|
|
102
134
|
"sideEffects": false,
|
|
103
135
|
"dependencies": {
|
|
104
|
-
"@motionstudies/core": "0.1.0-alpha.
|
|
136
|
+
"@motionstudies/core": "0.1.0-alpha.29",
|
|
105
137
|
"fast-xml-parser": "5.11.1"
|
|
106
138
|
}
|
|
107
139
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FeedHealth } from './feed-observability.mjs'
|
|
2
|
+
export interface RecorderEvidencePlan {
|
|
3
|
+
schemaVersion: 1
|
|
4
|
+
kind: 'recorder-evidence-plan'
|
|
5
|
+
producerId: string
|
|
6
|
+
validUntil: string
|
|
7
|
+
feeds: {
|
|
8
|
+
feedId: string
|
|
9
|
+
archive?: { journalPath: string; closeoutPath: string; expectedEnd: string; deadlineAt: string }
|
|
10
|
+
publication?: { pointerPath: string; operator: string; expectedThrough: string; deadlineAt: string }
|
|
11
|
+
}[]
|
|
12
|
+
}
|
|
13
|
+
/** Reads explicitly selected local files, with integrity checks, 256 MiB per journal and a 512 MiB total read budget. */
|
|
14
|
+
export function recorderEvidenceHealth(registry: unknown, report: unknown, plan: unknown): Promise<FeedHealth>
|
|
@@ -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,47 @@
|
|
|
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
|
+
/** Read one described file from a release directory: safe path, no escape through links, bounded, size and digest checked. */
|
|
10
|
+
export async function readReleaseFile(root, descriptor, { maxBytes = 512 * 1024 ** 2 } = {}) {
|
|
11
|
+
const d = readDescriptor(descriptor)
|
|
12
|
+
if (d.bytes > maxBytes) throw new Error(`${d.path} exceeds the read limit`)
|
|
13
|
+
const base = await realpath(resolve(root)), actual = await realpath(join(base, ...d.path.split('/')))
|
|
14
|
+
if (!inside(base, actual)) throw new Error(`${d.path} resolves outside the release`)
|
|
15
|
+
return verifyDescriptorBytes(await readOperationalFile(actual, d.bytes), d)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Open a release directory: read `manifest.json` (bounded), optionally require its pinned digest, check its kind and
|
|
20
|
+
* schema, and enumerate its files. Nothing but the manifest is read until asked.
|
|
21
|
+
*/
|
|
22
|
+
export async function openRelease(root, { kind, schemaVersions, manifestSha256, maxManifestBytes = 8 * 1024 ** 2, manifest: name = 'manifest.json' } = {}) {
|
|
23
|
+
if (!isReleasePath(name)) throw new Error('Unsafe manifest path')
|
|
24
|
+
const bytes = await readOperationalFile(join(resolve(root), name), maxManifestBytes)
|
|
25
|
+
if (manifestSha256 !== undefined && await digestHex(bytes) !== manifestSha256) throw new Error('Release manifest does not match its pinned digest')
|
|
26
|
+
const manifest = checkReleaseManifest(JSON.parse(bytes.toString('utf8')), { kind, schemaVersions })
|
|
27
|
+
const descriptors = releaseDescriptors(manifest), byPath = new Map(descriptors.map(d => [d.path, d]))
|
|
28
|
+
return {
|
|
29
|
+
manifest, manifestBytes: bytes, descriptors,
|
|
30
|
+
/** Read a described file by path or descriptor; a path the manifest does not list is refused. */
|
|
31
|
+
read: async (file, options) => {
|
|
32
|
+
const d = typeof file === 'string' ? byPath.get(file) : file
|
|
33
|
+
const listed = d && byPath.get(d.path)
|
|
34
|
+
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`)
|
|
35
|
+
return readReleaseFile(root, d, options)
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Verify every file a release describes against a total byte budget. */
|
|
41
|
+
export async function verifyRelease(root, { maxTotalBytes = 2 * 1024 ** 3, ...options } = {}) {
|
|
42
|
+
const release = await openRelease(root, options)
|
|
43
|
+
const total = release.descriptors.reduce((n, d) => n + d.bytes, 0)
|
|
44
|
+
if (total > maxTotalBytes) throw new Error('Release exceeds the verification budget')
|
|
45
|
+
for (const d of release.descriptors) await readReleaseFile(root, d)
|
|
46
|
+
return { manifest: release.manifest, manifestSha256: await digestHex(release.manifestBytes), files: release.descriptors.length, bytes: total }
|
|
47
|
+
}
|
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
|
+
}
|
package/uk-service-day.d.mts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export declare const SERVICE_TIMEZONE: 'Europe/London'
|
|
1
2
|
/** UK civil day boundaries. GTFS service time has a separate noon-based origin. */
|
|
2
3
|
export interface UkStudyDay {serviceDate:string;timezone:'Europe/London';startUtc:string;endUtc:string;durationSeconds:number}
|
|
3
4
|
export function checkedDate(value:string):string
|
package/uk-service-day.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const
|
|
1
|
+
export const SERVICE_TIMEZONE = 'Europe/London'
|
|
2
2
|
|
|
3
3
|
export function checkedDate(value) {
|
|
4
4
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value ?? '') || !Number.isFinite(Date.parse(`${value}T00:00:00Z`)) ||
|
|
@@ -11,7 +11,7 @@ export function checkedDate(value) {
|
|
|
11
11
|
// UK midnight is unambiguous on both DST transition days. Never infer a full day as 86400 seconds.
|
|
12
12
|
function londonMidnight(date) {
|
|
13
13
|
const nominal = Date.parse(`${date}T00:00:00Z`)
|
|
14
|
-
const formatter = new Intl.DateTimeFormat('en-GB', { timeZone:
|
|
14
|
+
const formatter = new Intl.DateTimeFormat('en-GB', { timeZone: SERVICE_TIMEZONE, timeZoneName: 'longOffset' })
|
|
15
15
|
let instant = nominal
|
|
16
16
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
17
17
|
const offset = formatter.formatToParts(instant).find(part => part.type === 'timeZoneName').value
|
|
@@ -28,7 +28,7 @@ export function studyDay(serviceDate) {
|
|
|
28
28
|
const next = new Date(Date.parse(`${serviceDate}T00:00:00Z`) + 86400000).toISOString().slice(0, 10)
|
|
29
29
|
const start = londonMidnight(serviceDate)
|
|
30
30
|
const end = londonMidnight(next)
|
|
31
|
-
return { serviceDate, timezone:
|
|
31
|
+
return { serviceDate, timezone: SERVICE_TIMEZONE, startUtc: new Date(start).toISOString(), endUtc: new Date(end).toISOString(), durationSeconds: (end - start) / 1000 }
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export function utcDatesForDay(day) {
|