dsh-plugin-show-me-data 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +27 -0
- package/README.md +96 -0
- package/cordis.patch.yml +40 -0
- package/docs/01-product-effect.md +178 -0
- package/docs/02-architecture.md +275 -0
- package/docs/03-data-contracts.md +291 -0
- package/docs/04-sources.md +342 -0
- package/docs/05-ui-spec.md +167 -0
- package/docs/06-ai-layer.md +194 -0
- package/docs/07-implementation-plan.md +399 -0
- package/docs/08-test-plan.md +133 -0
- package/docs/09-packaging-install.md +249 -0
- package/docs/10-kickoff-prompt.md +94 -0
- package/docs/11-decisions.md +203 -0
- package/docs/12-runtime-verified.md +115 -0
- package/docs/13-acceptance.md +153 -0
- package/docs/14-progress.md +150 -0
- package/docs/15-publish.md +185 -0
- package/lib/app/ai-deterministic.js +327 -0
- package/lib/app/ai-validate.js +284 -0
- package/lib/app/ai.js +440 -0
- package/lib/app/health.js +77 -0
- package/lib/app/overview.js +349 -0
- package/lib/app/propose-indicator.js +122 -0
- package/lib/app/refresh.js +251 -0
- package/lib/app/series-view.js +195 -0
- package/lib/app/watchlist.js +102 -0
- package/lib/client.js +4322 -0
- package/lib/core/ai/prompts.js +213 -0
- package/lib/core/chart/axis.js +133 -0
- package/lib/core/chart/bar.js +58 -0
- package/lib/core/chart/candle.js +216 -0
- package/lib/core/chart/line.js +186 -0
- package/lib/core/chart/scale.js +132 -0
- package/lib/core/format.js +143 -0
- package/lib/core/indicators/catalog.js +1011 -0
- package/lib/core/indicators/resolve.js +196 -0
- package/lib/core/insight/digest.js +250 -0
- package/lib/core/insight/rank.js +115 -0
- package/lib/core/insight/related.js +90 -0
- package/lib/core/insight/rules.js +417 -0
- package/lib/core/stats/derive.js +123 -0
- package/lib/core/stats/series.js +465 -0
- package/lib/core/time/range.js +242 -0
- package/lib/core/types.js +478 -0
- package/lib/host/ai/discussion.js +559 -0
- package/lib/host/ai/dsh-llm-gateway.js +333 -0
- package/lib/host/config.js +194 -0
- package/lib/host/http/respond.js +165 -0
- package/lib/host/http/routes.js +689 -0
- package/lib/host/index.js +293 -0
- package/lib/host/infra/fs-repos.js +179 -0
- package/lib/host/infra/memory-fallback.js +64 -0
- package/lib/host/tools/define-tool.js +295 -0
- package/lib/host/tools/register.js +431 -0
- package/lib/host.js +7 -0
- package/lib/ports/clock.js +57 -0
- package/lib/ports/snapshot-repo.js +48 -0
- package/lib/sources/eastmoney-macro.js +197 -0
- package/lib/sources/eastmoney-quote.js +201 -0
- package/lib/sources/ecb.js +179 -0
- package/lib/sources/fred.js +207 -0
- package/lib/sources/http.js +136 -0
- package/lib/sources/ohlc.js +36 -0
- package/lib/sources/quote-cascade.js +177 -0
- package/lib/sources/registry.js +153 -0
- package/lib/sources/sina-cn.js +197 -0
- package/lib/sources/sina-us.js +187 -0
- package/lib/sources/tencent.js +158 -0
- package/lib/sources/us-treasury-rates.js +275 -0
- package/lib/sources/us-treasury.js +196 -0
- package/lib/sources/worldbank.js +170 -0
- package/package.json +69 -0
- package/src/app/ai-deterministic.js +327 -0
- package/src/app/ai-validate.js +284 -0
- package/src/app/ai.js +440 -0
- package/src/app/health.js +77 -0
- package/src/app/overview.js +349 -0
- package/src/app/propose-indicator.js +122 -0
- package/src/app/refresh.js +251 -0
- package/src/app/series-view.js +195 -0
- package/src/app/watchlist.js +102 -0
- package/src/client/api.js +323 -0
- package/src/client/components.js +1877 -0
- package/src/client/copy.js +368 -0
- package/src/client/index.js +169 -0
- package/src/client/store.js +219 -0
- package/src/core/ai/prompts.js +213 -0
- package/src/core/chart/axis.js +133 -0
- package/src/core/chart/bar.js +58 -0
- package/src/core/chart/candle.js +216 -0
- package/src/core/chart/line.js +186 -0
- package/src/core/chart/scale.js +132 -0
- package/src/core/format.js +143 -0
- package/src/core/indicators/catalog.js +1011 -0
- package/src/core/indicators/resolve.js +196 -0
- package/src/core/insight/digest.js +250 -0
- package/src/core/insight/rank.js +115 -0
- package/src/core/insight/related.js +90 -0
- package/src/core/insight/rules.js +417 -0
- package/src/core/stats/derive.js +123 -0
- package/src/core/stats/series.js +465 -0
- package/src/core/time/range.js +242 -0
- package/src/core/types.js +478 -0
- package/src/host/ai/discussion.js +559 -0
- package/src/host/ai/dsh-llm-gateway.js +333 -0
- package/src/host/config.js +194 -0
- package/src/host/http/respond.js +165 -0
- package/src/host/http/routes.js +689 -0
- package/src/host/index.js +293 -0
- package/src/host/infra/fs-repos.js +179 -0
- package/src/host/infra/memory-fallback.js +64 -0
- package/src/host/tools/define-tool.js +295 -0
- package/src/host/tools/register.js +431 -0
- package/src/ports/clock.js +57 -0
- package/src/ports/snapshot-repo.js +48 -0
- package/src/sources/eastmoney-macro.js +197 -0
- package/src/sources/eastmoney-quote.js +201 -0
- package/src/sources/ecb.js +179 -0
- package/src/sources/fred.js +207 -0
- package/src/sources/http.js +136 -0
- package/src/sources/ohlc.js +36 -0
- package/src/sources/quote-cascade.js +177 -0
- package/src/sources/registry.js +153 -0
- package/src/sources/sina-cn.js +197 -0
- package/src/sources/sina-us.js +187 -0
- package/src/sources/tencent.js +158 -0
- package/src/sources/us-treasury-rates.js +275 -0
- package/src/sources/us-treasury.js +196 -0
- package/src/sources/worldbank.js +170 -0
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core contract validators — pure functions, zero IO, zero DSH imports.
|
|
3
|
+
*
|
|
4
|
+
* Every validator returns '{ ok, errors }' where 'errors' is a list of
|
|
5
|
+
* human-readable strings (never thrown). The CI gate runs these over the seed
|
|
6
|
+
* catalog and over every adapter fixture output (docs/08 §8).
|
|
7
|
+
*
|
|
8
|
+
* @module core/types
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Allowed indicator groups (docs/03 §1.2). */
|
|
12
|
+
export const GROUPS = ['US', 'CN', 'GLOBAL', 'CUSTOM']
|
|
13
|
+
|
|
14
|
+
/** Allowed frequencies, slowest last. */
|
|
15
|
+
export const FREQUENCIES = ['daily', 'weekly', 'monthly', 'quarterly', 'annual']
|
|
16
|
+
|
|
17
|
+
/** Allowed seasonal adjustments. */
|
|
18
|
+
export const SEASONALS = ['SA', 'NSA', 'NA']
|
|
19
|
+
|
|
20
|
+
/** Allowed display transforms. */
|
|
21
|
+
export const TRANSFORMS = [
|
|
22
|
+
'raw',
|
|
23
|
+
'diff',
|
|
24
|
+
'pctChange',
|
|
25
|
+
'yoy',
|
|
26
|
+
'mom',
|
|
27
|
+
'annualize',
|
|
28
|
+
'ratio',
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
/** Allowed polarities: which direction of change is "good". */
|
|
32
|
+
export const POLARITIES = ['up-is-good', 'down-is-good', 'neutral']
|
|
33
|
+
|
|
34
|
+
/** Allowed metric/series statuses (docs/03 §1.7). */
|
|
35
|
+
export const STATUSES = ['fresh', 'stale', 'error', 'missing']
|
|
36
|
+
|
|
37
|
+
/** Allowed derivation ops for derived indicators (docs/03 §3.5). */
|
|
38
|
+
export const DERIVE_OPS = ['spread', 'ratio', 'avg']
|
|
39
|
+
|
|
40
|
+
/** Allowed source-error kinds (docs/03 §1.7). */
|
|
41
|
+
export const ERROR_KINDS = ['network', 'http', 'parse', 'empty', 'unsupported']
|
|
42
|
+
|
|
43
|
+
/** 'YYYY-MM-DD' calendar date, no time component. */
|
|
44
|
+
export const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
|
45
|
+
|
|
46
|
+
/** Dotted lowercase id, at least two segments, no empty segment. */
|
|
47
|
+
const ID_RE = /^[a-z0-9]+(\.[a-z0-9]+)+$/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Validate a calendar date string: 'YYYY-MM-DD' and a real calendar day
|
|
51
|
+
* (rejects '2026-02-31').
|
|
52
|
+
*
|
|
53
|
+
* @param {unknown} value - candidate date.
|
|
54
|
+
* @returns {boolean} whether the value is a real 'YYYY-MM-DD' date.
|
|
55
|
+
*/
|
|
56
|
+
export function isDateString(value) {
|
|
57
|
+
if (typeof value !== 'string' || !DATE_RE.test(value)) return false
|
|
58
|
+
const [y, m, d] = value.split('-').map(Number)
|
|
59
|
+
if (m < 1 || m > 12 || d < 1) return false
|
|
60
|
+
// Reject impossible days (2026-02-31) without reading a clock: a constant
|
|
61
|
+
// epoch plus setUTCFullYear normalizes overflow, so a round-trip mismatch
|
|
62
|
+
// means the input was not a real calendar date.
|
|
63
|
+
const at = new Date(0)
|
|
64
|
+
at.setUTCFullYear(y, m - 1, d)
|
|
65
|
+
return at.getUTCFullYear() === y && at.getUTCMonth() === m - 1 && at.getUTCDate() === d
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Whether a value is a finite number (rejects NaN, Infinity, numeric strings).
|
|
70
|
+
*
|
|
71
|
+
* @param {unknown} value - candidate.
|
|
72
|
+
* @returns {boolean} true for finite 'number'.
|
|
73
|
+
*/
|
|
74
|
+
export function isFiniteNumber(value) {
|
|
75
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @typedef {{ ok: boolean, errors: string[] }} ValidationResult
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build a validation result from an error list.
|
|
84
|
+
*
|
|
85
|
+
* @param {string[]} errors - collected error messages.
|
|
86
|
+
* @returns {ValidationResult} result object.
|
|
87
|
+
*/
|
|
88
|
+
function result(errors) {
|
|
89
|
+
return { ok: errors.length === 0, errors }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Validate an 'IndicatorDef' (docs/03 §1.2).
|
|
94
|
+
*
|
|
95
|
+
* @param {unknown} value - candidate indicator definition.
|
|
96
|
+
* @returns {ValidationResult} validation result.
|
|
97
|
+
*/
|
|
98
|
+
export function validateIndicatorDef(value) {
|
|
99
|
+
const errors = []
|
|
100
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
101
|
+
return result(['indicator: expected an object'])
|
|
102
|
+
}
|
|
103
|
+
const def = /** @type {Record<string, any>} */ (value)
|
|
104
|
+
|
|
105
|
+
if (typeof def.id !== 'string' || !ID_RE.test(def.id)) {
|
|
106
|
+
errors.push(`id: expected dotted lowercase id (got ${JSON.stringify(def.id)})`)
|
|
107
|
+
}
|
|
108
|
+
if (!GROUPS.includes(def.group)) {
|
|
109
|
+
errors.push(`group: expected one of ${GROUPS.join('|')} (got ${JSON.stringify(def.group)})`)
|
|
110
|
+
}
|
|
111
|
+
if (def.label === null || typeof def.label !== 'object') {
|
|
112
|
+
errors.push('label: expected { zh, en }')
|
|
113
|
+
} else {
|
|
114
|
+
if (typeof def.label.zh !== 'string' || def.label.zh.trim() === '') {
|
|
115
|
+
errors.push('label.zh: expected a non-empty string')
|
|
116
|
+
}
|
|
117
|
+
if (typeof def.label.en !== 'string' || def.label.en.trim() === '') {
|
|
118
|
+
errors.push('label.en: expected a non-empty string')
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (typeof def.unit !== 'string' || def.unit === '') {
|
|
122
|
+
errors.push('unit: expected a non-empty string')
|
|
123
|
+
}
|
|
124
|
+
if (!FREQUENCIES.includes(def.freq)) {
|
|
125
|
+
errors.push(`freq: expected one of ${FREQUENCIES.join('|')}`)
|
|
126
|
+
}
|
|
127
|
+
if (!SEASONALS.includes(def.seasonal)) {
|
|
128
|
+
errors.push(`seasonal: expected one of ${SEASONALS.join('|')}`)
|
|
129
|
+
}
|
|
130
|
+
if (!Number.isInteger(def.importance) || def.importance < 1 || def.importance > 5) {
|
|
131
|
+
errors.push('importance: expected an integer in [1,5]')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const derived = def.derive !== undefined && def.derive !== null
|
|
135
|
+
if (derived) {
|
|
136
|
+
if (def.derive === null || typeof def.derive !== 'object') {
|
|
137
|
+
errors.push('derive: expected { op, operands }')
|
|
138
|
+
} else {
|
|
139
|
+
if (!DERIVE_OPS.includes(def.derive.op)) {
|
|
140
|
+
errors.push(`derive.op: expected one of ${DERIVE_OPS.join('|')}`)
|
|
141
|
+
}
|
|
142
|
+
if (!Array.isArray(def.derive.operands) || def.derive.operands.length < 2) {
|
|
143
|
+
errors.push('derive.operands: expected an array of at least 2 indicator ids')
|
|
144
|
+
} else if (def.derive.operands.some((id) => typeof id !== 'string')) {
|
|
145
|
+
errors.push('derive.operands: every operand must be a string')
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
const source = def.source
|
|
150
|
+
if (source === null || typeof source !== 'object') {
|
|
151
|
+
errors.push('source: expected { adapter, seriesRef }')
|
|
152
|
+
} else {
|
|
153
|
+
if (typeof source.adapter !== 'string' || source.adapter === '') {
|
|
154
|
+
errors.push('source.adapter: expected a non-empty string')
|
|
155
|
+
}
|
|
156
|
+
if (typeof source.seriesRef !== 'string' || source.seriesRef === '') {
|
|
157
|
+
errors.push('source.seriesRef: expected a non-empty string')
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const display = def.display
|
|
163
|
+
if (display === null || typeof display !== 'object') {
|
|
164
|
+
errors.push('display: expected an object')
|
|
165
|
+
} else {
|
|
166
|
+
if (!TRANSFORMS.includes(display.transform)) {
|
|
167
|
+
errors.push(`display.transform: expected one of ${TRANSFORMS.join('|')}`)
|
|
168
|
+
}
|
|
169
|
+
if (display.window !== undefined && (!Number.isInteger(display.window) || display.window < 1)) {
|
|
170
|
+
errors.push('display.window: expected a positive integer')
|
|
171
|
+
}
|
|
172
|
+
if (display.movingAvg !== undefined && (!Number.isInteger(display.movingAvg) || display.movingAvg < 2)) {
|
|
173
|
+
errors.push('display.movingAvg: expected an integer >= 2')
|
|
174
|
+
}
|
|
175
|
+
if (display.decimals !== undefined && (!Number.isInteger(display.decimals) || display.decimals < 0 || display.decimals > 8)) {
|
|
176
|
+
errors.push('display.decimals: expected an integer in [0,8]')
|
|
177
|
+
}
|
|
178
|
+
if (display.polarity !== undefined && !POLARITIES.includes(display.polarity)) {
|
|
179
|
+
errors.push(`display.polarity: expected one of ${POLARITIES.join('|')}`)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (def.notes !== undefined && def.notes !== null) {
|
|
183
|
+
if (typeof def.notes !== 'object' || typeof def.notes.zh !== 'string') {
|
|
184
|
+
errors.push('notes: expected { zh, en? }')
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (def.tags !== undefined && (!Array.isArray(def.tags) || def.tags.some((t) => typeof t !== 'string'))) {
|
|
188
|
+
errors.push('tags: expected an array of strings')
|
|
189
|
+
}
|
|
190
|
+
return result(errors)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Validate a 'RawSeries' (docs/03 §1.3): ascending 't', finite 'v', real
|
|
195
|
+
* 'YYYY-MM-DD' dates, non-empty 'sourceRef.url'.
|
|
196
|
+
*
|
|
197
|
+
* @param {unknown} value - candidate raw series.
|
|
198
|
+
* @returns {ValidationResult} validation result.
|
|
199
|
+
*/
|
|
200
|
+
export function validateRawSeries(value) {
|
|
201
|
+
const errors = []
|
|
202
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
203
|
+
return result(['rawSeries: expected an object'])
|
|
204
|
+
}
|
|
205
|
+
const series = /** @type {Record<string, any>} */ (value)
|
|
206
|
+
|
|
207
|
+
if (typeof series.adapterId !== 'string' || series.adapterId === '') {
|
|
208
|
+
errors.push('adapterId: expected a non-empty string')
|
|
209
|
+
}
|
|
210
|
+
if (typeof series.seriesRef !== 'string' || series.seriesRef === '') {
|
|
211
|
+
errors.push('seriesRef: expected a non-empty string')
|
|
212
|
+
}
|
|
213
|
+
if (!Array.isArray(series.points)) {
|
|
214
|
+
errors.push('points: expected an array')
|
|
215
|
+
} else {
|
|
216
|
+
let prev = null
|
|
217
|
+
for (let i = 0; i < series.points.length; i += 1) {
|
|
218
|
+
const point = series.points[i]
|
|
219
|
+
if (point === null || typeof point !== 'object') {
|
|
220
|
+
errors.push(`points[${i}]: expected { t, v }`)
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
if (!isDateString(point.t)) {
|
|
224
|
+
errors.push(`points[${i}].t: expected a real YYYY-MM-DD date (got ${JSON.stringify(point.t)})`)
|
|
225
|
+
} else if (prev !== null && point.t < prev) {
|
|
226
|
+
errors.push(`points[${i}].t: points must be ascending (${point.t} after ${prev})`)
|
|
227
|
+
}
|
|
228
|
+
if (isDateString(point.t)) prev = point.t
|
|
229
|
+
// Optional OHLC bar: all three or none, and the high/low must actually
|
|
230
|
+
// bound the open/close, so a malformed bar is reported here instead of
|
|
231
|
+
// rendering as an impossible candle.
|
|
232
|
+
const bar = ['o', 'h', 'l'].filter((key) => point[key] !== undefined)
|
|
233
|
+
if (bar.length !== 0 && bar.length !== 3) {
|
|
234
|
+
errors.push(`points[${i}]: an OHLC bar needs all of o/h/l (got ${bar.join(', ') || 'none'})`)
|
|
235
|
+
} else if (bar.length === 3) {
|
|
236
|
+
for (const key of bar) {
|
|
237
|
+
if (!isFiniteNumber(point[key])) errors.push(`points[${i}].${key}: expected a finite number`)
|
|
238
|
+
}
|
|
239
|
+
if (isFiniteNumber(point.h) && isFiniteNumber(point.l)) {
|
|
240
|
+
const bound = [point.h, point.l, point.o, point.v].filter((entry) => isFiniteNumber(entry))
|
|
241
|
+
if (bar.every((key) => isFiniteNumber(point[key])) && (point.h < Math.max(...bound) || point.l > Math.min(...bound))) {
|
|
242
|
+
errors.push(`points[${i}]: high/low must bound open and close`)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (!isFiniteNumber(point.v)) {
|
|
247
|
+
errors.push(`points[${i}].v: expected a finite number (got ${JSON.stringify(point.v)})`)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (series.meta === null || typeof series.meta !== 'object') {
|
|
252
|
+
errors.push('meta: expected an object with a name')
|
|
253
|
+
} else if (typeof series.meta.name !== 'string' || series.meta.name === '') {
|
|
254
|
+
errors.push('meta.name: expected a non-empty string')
|
|
255
|
+
}
|
|
256
|
+
if (typeof series.fetchedAt !== 'string' || series.fetchedAt === '') {
|
|
257
|
+
errors.push('fetchedAt: expected an ISO timestamp string')
|
|
258
|
+
}
|
|
259
|
+
const ref = series.sourceRef
|
|
260
|
+
if (ref === null || typeof ref !== 'object') {
|
|
261
|
+
errors.push('sourceRef: expected an object')
|
|
262
|
+
} else {
|
|
263
|
+
if (typeof ref.adapterId !== 'string' || ref.adapterId === '') {
|
|
264
|
+
errors.push('sourceRef.adapterId: expected a non-empty string')
|
|
265
|
+
}
|
|
266
|
+
if (typeof ref.seriesRef !== 'string' || ref.seriesRef === '') {
|
|
267
|
+
errors.push('sourceRef.seriesRef: expected a non-empty string')
|
|
268
|
+
}
|
|
269
|
+
if (typeof ref.url !== 'string' || !/^https:\/\/\S+$/.test(ref.url)) {
|
|
270
|
+
errors.push('sourceRef.url: expected a non-empty https:// URL')
|
|
271
|
+
}
|
|
272
|
+
if (typeof ref.label !== 'string' || ref.label === '') {
|
|
273
|
+
errors.push('sourceRef.label: expected a non-empty string')
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return result(errors)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Validate a 'SeriesStats' object's required fields and status-independent
|
|
281
|
+
* invariants (docs/03 §1.4).
|
|
282
|
+
*
|
|
283
|
+
* @param {unknown} value - candidate stats object.
|
|
284
|
+
* @returns {ValidationResult} validation result.
|
|
285
|
+
*/
|
|
286
|
+
export function validateSeriesStats(value) {
|
|
287
|
+
const errors = []
|
|
288
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
289
|
+
return result(['stats: expected an object'])
|
|
290
|
+
}
|
|
291
|
+
const stats = /** @type {Record<string, any>} */ (value)
|
|
292
|
+
if (!isFiniteNumber(stats.latest)) errors.push('stats.latest: expected a finite number')
|
|
293
|
+
if (!isDateString(stats.latestAt)) errors.push('stats.latestAt: expected a real YYYY-MM-DD date')
|
|
294
|
+
for (const key of ['mean', 'min', 'max', 'stdDev', 'missingCount']) {
|
|
295
|
+
if (!isFiniteNumber(stats[key])) errors.push(`stats.${key}: expected a finite number`)
|
|
296
|
+
}
|
|
297
|
+
for (const key of ['prev', 'changeAbs', 'changePct', 'yoy', 'mom', 'zScoreLatestChange', 'slope', 'percentile']) {
|
|
298
|
+
const field = stats[key]
|
|
299
|
+
if (field !== undefined && field !== null && !isFiniteNumber(field)) {
|
|
300
|
+
errors.push(`stats.${key}: expected a finite number when present`)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (isFiniteNumber(stats.percentile) && (stats.percentile < 0 || stats.percentile > 1)) {
|
|
304
|
+
errors.push('stats.percentile: expected a value in [0,1]')
|
|
305
|
+
}
|
|
306
|
+
return result(errors)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Validate a 'Metric' card (docs/03 §1.5).
|
|
311
|
+
*
|
|
312
|
+
* @param {unknown} value - candidate metric.
|
|
313
|
+
* @returns {ValidationResult} validation result.
|
|
314
|
+
*/
|
|
315
|
+
export function validateMetric(value) {
|
|
316
|
+
const errors = []
|
|
317
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
318
|
+
return result(['metric: expected an object'])
|
|
319
|
+
}
|
|
320
|
+
const metric = /** @type {Record<string, any>} */ (value)
|
|
321
|
+
if (typeof metric.indicatorId !== 'string' || !ID_RE.test(metric.indicatorId)) {
|
|
322
|
+
errors.push('indicatorId: expected a dotted lowercase id')
|
|
323
|
+
}
|
|
324
|
+
if (metric.label === null || typeof metric.label !== 'object') {
|
|
325
|
+
errors.push('label: expected { zh, en }')
|
|
326
|
+
} else if (typeof metric.label.zh !== 'string' || metric.label.zh === '') {
|
|
327
|
+
errors.push('label.zh: expected a non-empty string')
|
|
328
|
+
}
|
|
329
|
+
if (typeof metric.unit !== 'string') errors.push('unit: expected a string')
|
|
330
|
+
if (!STATUSES.includes(metric.status)) {
|
|
331
|
+
errors.push(`status: expected one of ${STATUSES.join('|')}`)
|
|
332
|
+
}
|
|
333
|
+
if (metric.status !== 'error' && !isFiniteNumber(metric.latest)) {
|
|
334
|
+
errors.push('latest: expected a finite number unless status is error')
|
|
335
|
+
}
|
|
336
|
+
if (metric.latestAt !== undefined && !isDateString(metric.latestAt)) {
|
|
337
|
+
errors.push('latestAt: expected a real YYYY-MM-DD date when present')
|
|
338
|
+
}
|
|
339
|
+
if (!Array.isArray(metric.sparkline)) {
|
|
340
|
+
errors.push('sparkline: expected an array of numbers')
|
|
341
|
+
} else if (metric.sparkline.some((v) => !isFiniteNumber(v))) {
|
|
342
|
+
errors.push('sparkline: every value must be finite')
|
|
343
|
+
}
|
|
344
|
+
if (!Array.isArray(metric.hits)) errors.push('hits: expected an array')
|
|
345
|
+
if (!isFiniteNumber(metric.score)) errors.push('score: expected a finite number')
|
|
346
|
+
const ref = metric.sourceRef
|
|
347
|
+
if (ref === null || typeof ref !== 'object' || typeof ref.url !== 'string' || !/^https:\/\//.test(ref.url)) {
|
|
348
|
+
errors.push('sourceRef.url: expected an https:// URL')
|
|
349
|
+
}
|
|
350
|
+
return result(errors)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Validate a 'SeriesView' (docs/03 §1.4).
|
|
355
|
+
*
|
|
356
|
+
* @param {unknown} value - candidate series view.
|
|
357
|
+
* @returns {ValidationResult} validation result.
|
|
358
|
+
*/
|
|
359
|
+
export function validateSeriesView(value) {
|
|
360
|
+
const errors = []
|
|
361
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
362
|
+
return result(['seriesView: expected an object'])
|
|
363
|
+
}
|
|
364
|
+
const view = /** @type {Record<string, any>} */ (value)
|
|
365
|
+
if (typeof view.indicatorId !== 'string' || !ID_RE.test(view.indicatorId)) {
|
|
366
|
+
errors.push('indicatorId: expected a dotted lowercase id')
|
|
367
|
+
}
|
|
368
|
+
const range = view.range
|
|
369
|
+
if (range === null || typeof range !== 'object' || !isDateString(range.from) || !isDateString(range.to)) {
|
|
370
|
+
errors.push('range: expected { from, to } as YYYY-MM-DD')
|
|
371
|
+
}
|
|
372
|
+
if (!Array.isArray(view.points)) {
|
|
373
|
+
errors.push('points: expected an array')
|
|
374
|
+
} else if (view.points.some((p) => p === null || typeof p !== 'object' || !isDateString(p.t) || !isFiniteNumber(p.v))) {
|
|
375
|
+
errors.push('points: every point must be { t: YYYY-MM-DD, v: finite number }')
|
|
376
|
+
}
|
|
377
|
+
if (!STATUSES.includes(view.status)) {
|
|
378
|
+
errors.push(`status: expected one of ${STATUSES.join('|')}`)
|
|
379
|
+
}
|
|
380
|
+
if (view.stats !== undefined && view.stats !== null) {
|
|
381
|
+
for (const error of validateSeriesStats(view.stats).errors) errors.push(`stats: ${error}`)
|
|
382
|
+
}
|
|
383
|
+
const ref = view.sourceRef
|
|
384
|
+
if (ref === null || typeof ref !== 'object' || typeof ref.url !== 'string' || !/^https:\/\//.test(ref.url)) {
|
|
385
|
+
errors.push('sourceRef.url: expected an https:// URL')
|
|
386
|
+
}
|
|
387
|
+
return result(errors)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Validate a 'SourceError' (docs/03 §1.7).
|
|
392
|
+
*
|
|
393
|
+
* @param {unknown} value - candidate error payload.
|
|
394
|
+
* @returns {ValidationResult} validation result.
|
|
395
|
+
*/
|
|
396
|
+
export function validateSourceError(value) {
|
|
397
|
+
const errors = []
|
|
398
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
399
|
+
return result(['sourceError: expected an object'])
|
|
400
|
+
}
|
|
401
|
+
const error = /** @type {Record<string, any>} */ (value)
|
|
402
|
+
if (!ERROR_KINDS.includes(error.kind)) {
|
|
403
|
+
errors.push(`kind: expected one of ${ERROR_KINDS.join('|')}`)
|
|
404
|
+
}
|
|
405
|
+
if (typeof error.adapterId !== 'string') errors.push('adapterId: expected a string')
|
|
406
|
+
if (typeof error.seriesRef !== 'string') errors.push('seriesRef: expected a string')
|
|
407
|
+
if (typeof error.detail !== 'string') errors.push('detail: expected a string')
|
|
408
|
+
if (typeof error.retryable !== 'boolean') errors.push('retryable: expected a boolean')
|
|
409
|
+
return result(errors)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* A classified source failure (docs/03 §1.7). Adapters throw exactly this.
|
|
414
|
+
*/
|
|
415
|
+
export class SourceError extends Error {
|
|
416
|
+
/**
|
|
417
|
+
* @param {object} init - error fields.
|
|
418
|
+
* @param {'network'|'http'|'parse'|'empty'|'unsupported'} init.kind - classification.
|
|
419
|
+
* @param {string} init.adapterId - owning adapter id.
|
|
420
|
+
* @param {string} init.seriesRef - requested series reference.
|
|
421
|
+
* @param {string} init.detail - human-readable detail.
|
|
422
|
+
* @param {number} [init.httpStatus] - upstream status when known.
|
|
423
|
+
*/
|
|
424
|
+
constructor({ kind, adapterId, seriesRef, detail, httpStatus }) {
|
|
425
|
+
super(`${adapterId}/${seriesRef}: ${detail}`)
|
|
426
|
+
this.name = 'SourceError'
|
|
427
|
+
/** @type {'network'|'http'|'parse'|'empty'|'unsupported'} */
|
|
428
|
+
this.kind = kind
|
|
429
|
+
/** @type {string} */
|
|
430
|
+
this.adapterId = adapterId
|
|
431
|
+
/** @type {string} */
|
|
432
|
+
this.seriesRef = seriesRef
|
|
433
|
+
/** @type {string} */
|
|
434
|
+
this.detail = detail
|
|
435
|
+
if (httpStatus !== undefined) this.httpStatus = httpStatus
|
|
436
|
+
/**
|
|
437
|
+
* Only transport-level failures are worth a retry: a wrong series id
|
|
438
|
+
* ('unsupported') or a shape change ('parse') will fail again identically.
|
|
439
|
+
*/
|
|
440
|
+
this.retryable = kind === 'network' || (kind === 'http' && (httpStatus ?? 0) >= 500)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Lossless-JSON projection for transport.
|
|
445
|
+
*
|
|
446
|
+
* @returns {object} plain JSON payload.
|
|
447
|
+
*/
|
|
448
|
+
toJSON() {
|
|
449
|
+
return {
|
|
450
|
+
kind: this.kind,
|
|
451
|
+
adapterId: this.adapterId,
|
|
452
|
+
seriesRef: this.seriesRef,
|
|
453
|
+
detail: this.detail,
|
|
454
|
+
retryable: this.retryable,
|
|
455
|
+
...(this.httpStatus === undefined ? {} : { httpStatus: this.httpStatus }),
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Coerce any thrown value into a 'SourceError'-shaped JSON payload.
|
|
462
|
+
*
|
|
463
|
+
* @param {unknown} error - thrown value.
|
|
464
|
+
* @param {string} adapterId - owning adapter id.
|
|
465
|
+
* @param {string} seriesRef - requested series reference.
|
|
466
|
+
* @returns {{ kind: string, adapterId: string, seriesRef: string, detail: string, retryable: boolean, httpStatus?: number }} payload.
|
|
467
|
+
*/
|
|
468
|
+
export function toSourceErrorPayload(error, adapterId, seriesRef) {
|
|
469
|
+
if (error instanceof SourceError) return error.toJSON()
|
|
470
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
471
|
+
return {
|
|
472
|
+
kind: 'parse',
|
|
473
|
+
adapterId,
|
|
474
|
+
seriesRef,
|
|
475
|
+
detail,
|
|
476
|
+
retryable: false,
|
|
477
|
+
}
|
|
478
|
+
}
|