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.
Files changed (130) hide show
  1. package/LICENSE +27 -0
  2. package/README.md +96 -0
  3. package/cordis.patch.yml +40 -0
  4. package/docs/01-product-effect.md +178 -0
  5. package/docs/02-architecture.md +275 -0
  6. package/docs/03-data-contracts.md +291 -0
  7. package/docs/04-sources.md +342 -0
  8. package/docs/05-ui-spec.md +167 -0
  9. package/docs/06-ai-layer.md +194 -0
  10. package/docs/07-implementation-plan.md +399 -0
  11. package/docs/08-test-plan.md +133 -0
  12. package/docs/09-packaging-install.md +249 -0
  13. package/docs/10-kickoff-prompt.md +94 -0
  14. package/docs/11-decisions.md +203 -0
  15. package/docs/12-runtime-verified.md +115 -0
  16. package/docs/13-acceptance.md +153 -0
  17. package/docs/14-progress.md +150 -0
  18. package/docs/15-publish.md +185 -0
  19. package/lib/app/ai-deterministic.js +327 -0
  20. package/lib/app/ai-validate.js +284 -0
  21. package/lib/app/ai.js +440 -0
  22. package/lib/app/health.js +77 -0
  23. package/lib/app/overview.js +349 -0
  24. package/lib/app/propose-indicator.js +122 -0
  25. package/lib/app/refresh.js +251 -0
  26. package/lib/app/series-view.js +195 -0
  27. package/lib/app/watchlist.js +102 -0
  28. package/lib/client.js +4322 -0
  29. package/lib/core/ai/prompts.js +213 -0
  30. package/lib/core/chart/axis.js +133 -0
  31. package/lib/core/chart/bar.js +58 -0
  32. package/lib/core/chart/candle.js +216 -0
  33. package/lib/core/chart/line.js +186 -0
  34. package/lib/core/chart/scale.js +132 -0
  35. package/lib/core/format.js +143 -0
  36. package/lib/core/indicators/catalog.js +1011 -0
  37. package/lib/core/indicators/resolve.js +196 -0
  38. package/lib/core/insight/digest.js +250 -0
  39. package/lib/core/insight/rank.js +115 -0
  40. package/lib/core/insight/related.js +90 -0
  41. package/lib/core/insight/rules.js +417 -0
  42. package/lib/core/stats/derive.js +123 -0
  43. package/lib/core/stats/series.js +465 -0
  44. package/lib/core/time/range.js +242 -0
  45. package/lib/core/types.js +478 -0
  46. package/lib/host/ai/discussion.js +559 -0
  47. package/lib/host/ai/dsh-llm-gateway.js +333 -0
  48. package/lib/host/config.js +194 -0
  49. package/lib/host/http/respond.js +165 -0
  50. package/lib/host/http/routes.js +689 -0
  51. package/lib/host/index.js +293 -0
  52. package/lib/host/infra/fs-repos.js +179 -0
  53. package/lib/host/infra/memory-fallback.js +64 -0
  54. package/lib/host/tools/define-tool.js +295 -0
  55. package/lib/host/tools/register.js +431 -0
  56. package/lib/host.js +7 -0
  57. package/lib/ports/clock.js +57 -0
  58. package/lib/ports/snapshot-repo.js +48 -0
  59. package/lib/sources/eastmoney-macro.js +197 -0
  60. package/lib/sources/eastmoney-quote.js +201 -0
  61. package/lib/sources/ecb.js +179 -0
  62. package/lib/sources/fred.js +207 -0
  63. package/lib/sources/http.js +136 -0
  64. package/lib/sources/ohlc.js +36 -0
  65. package/lib/sources/quote-cascade.js +177 -0
  66. package/lib/sources/registry.js +153 -0
  67. package/lib/sources/sina-cn.js +197 -0
  68. package/lib/sources/sina-us.js +187 -0
  69. package/lib/sources/tencent.js +158 -0
  70. package/lib/sources/us-treasury-rates.js +275 -0
  71. package/lib/sources/us-treasury.js +196 -0
  72. package/lib/sources/worldbank.js +170 -0
  73. package/package.json +69 -0
  74. package/src/app/ai-deterministic.js +327 -0
  75. package/src/app/ai-validate.js +284 -0
  76. package/src/app/ai.js +440 -0
  77. package/src/app/health.js +77 -0
  78. package/src/app/overview.js +349 -0
  79. package/src/app/propose-indicator.js +122 -0
  80. package/src/app/refresh.js +251 -0
  81. package/src/app/series-view.js +195 -0
  82. package/src/app/watchlist.js +102 -0
  83. package/src/client/api.js +323 -0
  84. package/src/client/components.js +1877 -0
  85. package/src/client/copy.js +368 -0
  86. package/src/client/index.js +169 -0
  87. package/src/client/store.js +219 -0
  88. package/src/core/ai/prompts.js +213 -0
  89. package/src/core/chart/axis.js +133 -0
  90. package/src/core/chart/bar.js +58 -0
  91. package/src/core/chart/candle.js +216 -0
  92. package/src/core/chart/line.js +186 -0
  93. package/src/core/chart/scale.js +132 -0
  94. package/src/core/format.js +143 -0
  95. package/src/core/indicators/catalog.js +1011 -0
  96. package/src/core/indicators/resolve.js +196 -0
  97. package/src/core/insight/digest.js +250 -0
  98. package/src/core/insight/rank.js +115 -0
  99. package/src/core/insight/related.js +90 -0
  100. package/src/core/insight/rules.js +417 -0
  101. package/src/core/stats/derive.js +123 -0
  102. package/src/core/stats/series.js +465 -0
  103. package/src/core/time/range.js +242 -0
  104. package/src/core/types.js +478 -0
  105. package/src/host/ai/discussion.js +559 -0
  106. package/src/host/ai/dsh-llm-gateway.js +333 -0
  107. package/src/host/config.js +194 -0
  108. package/src/host/http/respond.js +165 -0
  109. package/src/host/http/routes.js +689 -0
  110. package/src/host/index.js +293 -0
  111. package/src/host/infra/fs-repos.js +179 -0
  112. package/src/host/infra/memory-fallback.js +64 -0
  113. package/src/host/tools/define-tool.js +295 -0
  114. package/src/host/tools/register.js +431 -0
  115. package/src/ports/clock.js +57 -0
  116. package/src/ports/snapshot-repo.js +48 -0
  117. package/src/sources/eastmoney-macro.js +197 -0
  118. package/src/sources/eastmoney-quote.js +201 -0
  119. package/src/sources/ecb.js +179 -0
  120. package/src/sources/fred.js +207 -0
  121. package/src/sources/http.js +136 -0
  122. package/src/sources/ohlc.js +36 -0
  123. package/src/sources/quote-cascade.js +177 -0
  124. package/src/sources/registry.js +153 -0
  125. package/src/sources/sina-cn.js +197 -0
  126. package/src/sources/sina-us.js +187 -0
  127. package/src/sources/tencent.js +158 -0
  128. package/src/sources/us-treasury-rates.js +275 -0
  129. package/src/sources/us-treasury.js +196 -0
  130. 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
+ }