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,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host half — the Cordis plugin the profile mounts (docs/09 §4).
|
|
3
|
+
*
|
|
4
|
+
* Composition only: it wires the ports (clock, repositories, adapters, gateways)
|
|
5
|
+
* to the use cases, then contributes routes, tools and a refresh timer. Every
|
|
6
|
+
* contribution is disposed when the plugin unloads, which is asserted by
|
|
7
|
+
* 'test/host/plugin-shape.test.js'.
|
|
8
|
+
*
|
|
9
|
+
* @module host/index
|
|
10
|
+
*/
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
|
|
13
|
+
import { createRefreshService } from '../app/refresh.js'
|
|
14
|
+
import { createOverviewUseCase } from '../app/overview.js'
|
|
15
|
+
import { createSeriesViewUseCase } from '../app/series-view.js'
|
|
16
|
+
import { createWatchlistUseCase } from '../app/watchlist.js'
|
|
17
|
+
import { createHealthUseCase } from '../app/health.js'
|
|
18
|
+
import { createAiUseCases } from '../app/ai.js'
|
|
19
|
+
import { createDeterministicGateway } from '../app/ai-deterministic.js'
|
|
20
|
+
import { createProposeUseCase } from '../app/propose-indicator.js'
|
|
21
|
+
import { searchIndicators } from '../core/indicators/resolve.js'
|
|
22
|
+
import { CATALOG, indexById, UNSUPPORTED } from '../core/indicators/catalog.js'
|
|
23
|
+
import { hasAdapter, fetchViaAdapter, adapterIds, sourceTimeoutMs, supportsBarSize } from '../sources/registry.js'
|
|
24
|
+
import { systemClock } from '../ports/clock.js'
|
|
25
|
+
import { createFileSnapshotRepository, createFileWatchlistRepository } from './infra/fs-repos.js'
|
|
26
|
+
import { createRoutes, ROUTES } from './http/routes.js'
|
|
27
|
+
import { registerTools, TOOL_NAMES } from './tools/register.js'
|
|
28
|
+
import { API_PREFIX } from './http/respond.js'
|
|
29
|
+
import { createDshLlmGateway } from './ai/dsh-llm-gateway.js'
|
|
30
|
+
import { memorySnapshotRepository, memoryWatchlistRepository } from './infra/memory-fallback.js'
|
|
31
|
+
import { ConfigSchema, DEFAULT_CONFIG, resolveConfig } from './config.js'
|
|
32
|
+
import { createDiscussionGateway } from './ai/discussion.js'
|
|
33
|
+
|
|
34
|
+
/** Plugin name, as it appears in the loader tree. */
|
|
35
|
+
export const name = 'show-me-data'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Absolute path of this package's root — the workspace an iteration session runs in.
|
|
39
|
+
*
|
|
40
|
+
* Derived from the module URL rather than configured, so it is correct whether the
|
|
41
|
+
* plugin is loaded from `src/` or from the published `lib/` tree (`lib/host.js`
|
|
42
|
+
* sits at the same depth).
|
|
43
|
+
*/
|
|
44
|
+
export const PLUGIN_ROOT = fileURLToPath(new URL('../..', import.meta.url)).replace(/\/$/, '')
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Hard dependencies. Optional services ('llm', 'agentDefaultModel', 'timer') are
|
|
49
|
+
* read with 'ctx.get' and degrade instead of blocking activation.
|
|
50
|
+
*/
|
|
51
|
+
export const inject = ['webServer', 'tools']
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The plugin's config schema, read by the loader **before** 'apply' runs.
|
|
55
|
+
*
|
|
56
|
+
* This must be a Standard Schema ('Config["~standard"].validate'), not a plain
|
|
57
|
+
* defaults object: Cordis calls that method during row resolution, so a plain
|
|
58
|
+
* object crashes the entire profile at boot rather than failing this one row.
|
|
59
|
+
* See 'src/host/config.js' for the validation rules and the defaults.
|
|
60
|
+
*/
|
|
61
|
+
export const Config = ConfigSchema
|
|
62
|
+
|
|
63
|
+
/** The row defaults, exported for documentation and tests. */
|
|
64
|
+
export const defaults = DEFAULT_CONFIG
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Mount the plugin.
|
|
68
|
+
*
|
|
69
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - host context.
|
|
70
|
+
* @param {object} [config] - row configuration.
|
|
71
|
+
* @returns {() => void} disposer.
|
|
72
|
+
*/
|
|
73
|
+
export function apply(ctx, config = {}) {
|
|
74
|
+
// `resolveConfig` returns `{ value, issues }`; by the time `apply` runs the
|
|
75
|
+
// loader has already validated the row, so the normalized value is what we use.
|
|
76
|
+
const { value: resolved } = resolveConfig(config)
|
|
77
|
+
// Diagnostics must be readable even when the deployment's logger is quiet (the
|
|
78
|
+
// web profile's default), because "why did the model return nothing" is a
|
|
79
|
+
// question only the host can answer about its own process.
|
|
80
|
+
const log = (message, meta) => {
|
|
81
|
+
try {
|
|
82
|
+
ctx.logger?.info?.(`[show-me-data] ${message}`, meta ?? '')
|
|
83
|
+
} catch {
|
|
84
|
+
// Logging must never break the data path.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const logVerbose = (message, meta) => {
|
|
88
|
+
log(message, meta)
|
|
89
|
+
if (resolved.debug === true) {
|
|
90
|
+
try {
|
|
91
|
+
console.error(`[show-me-data] ${message}`, meta === undefined ? '' : JSON.stringify(meta))
|
|
92
|
+
} catch {
|
|
93
|
+
// ignore
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const clock = systemClock()
|
|
99
|
+
const snapshots = resolved.storageDir === undefined
|
|
100
|
+
? memorySnapshotRepository()
|
|
101
|
+
: createFileSnapshotRepository({ file: `${resolved.storageDir}/snapshots.json` })
|
|
102
|
+
const watchlistRepo = resolved.storageDir === undefined
|
|
103
|
+
? memoryWatchlistRepository()
|
|
104
|
+
: createFileWatchlistRepository({ file: `${resolved.storageDir}/watchlist.json` })
|
|
105
|
+
|
|
106
|
+
const catalogById = Object.fromEntries(indexById())
|
|
107
|
+
const runtime = { fetch: (...args) => globalThis.fetch(...args), clock }
|
|
108
|
+
|
|
109
|
+
const refresh = createRefreshService({
|
|
110
|
+
snapshots,
|
|
111
|
+
clock,
|
|
112
|
+
fetchViaAdapter: (adapterId, req, deps) => {
|
|
113
|
+
if (resolved.sources[adapterId] === false) {
|
|
114
|
+
return Promise.reject(Object.assign(new Error(`source ${adapterId} is disabled by configuration`), { name: 'SourceDisabled' }))
|
|
115
|
+
}
|
|
116
|
+
return fetchViaAdapter(adapterId, req, deps)
|
|
117
|
+
},
|
|
118
|
+
log,
|
|
119
|
+
options: { ttlMinutes: resolved.cacheTtl, timeoutFor: sourceTimeoutMs },
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const overview = createOverviewUseCase({ catalog: CATALOG, catalogById, refresh, clock, runtime, log })
|
|
123
|
+
const seriesView = createSeriesViewUseCase({ catalogById, refresh, clock, runtime })
|
|
124
|
+
const watchlist = createWatchlistUseCase({ repository: watchlistRepo, clock, catalogById })
|
|
125
|
+
const health = createHealthUseCase({ overview, clock, log })
|
|
126
|
+
|
|
127
|
+
const deterministic = createDeterministicGateway()
|
|
128
|
+
const gateway = buildGateway({ ctx, resolved, log })
|
|
129
|
+
// Built before the AI use cases because `createAiUseCases` needs it injected:
|
|
130
|
+
// without this argument `ai.propose` silently fell back to a stub and the
|
|
131
|
+
// route answered 500 ("propose is not a function").
|
|
132
|
+
const propose = createProposeUseCase({ gateway, catalogById, refresh, clock, runtime, hasAdapter, log })
|
|
133
|
+
// Discussion sessions inherit the workspace root by default (`storageDir` when
|
|
134
|
+
// the row sets one), so they open beside the session that spawned them.
|
|
135
|
+
const discussion = createDiscussionGateway({
|
|
136
|
+
ctx,
|
|
137
|
+
clock,
|
|
138
|
+
log,
|
|
139
|
+
options: { cwd: resolved.storageDir ?? (typeof process !== 'undefined' ? process.cwd() : undefined) },
|
|
140
|
+
})
|
|
141
|
+
const ai = createAiUseCases({
|
|
142
|
+
gateway,
|
|
143
|
+
fallback: deterministic,
|
|
144
|
+
overview,
|
|
145
|
+
catalogById,
|
|
146
|
+
snapshots,
|
|
147
|
+
clock,
|
|
148
|
+
propose,
|
|
149
|
+
displaySeries: overview.displaySeries,
|
|
150
|
+
options: { cacheMinutes: resolved.ai.cacheMinutes },
|
|
151
|
+
log: logVerbose,
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
const useCases = {
|
|
155
|
+
overview: overview.overview,
|
|
156
|
+
seriesView: seriesView.seriesView,
|
|
157
|
+
watchlist,
|
|
158
|
+
health: health.health,
|
|
159
|
+
search: (query, options = {}) => {
|
|
160
|
+
const result = searchIndicators(query, { limit: 12, ...options })
|
|
161
|
+
return {
|
|
162
|
+
matches: result.matches.map((entry) => ({ ...entry.indicator, score: entry.score })),
|
|
163
|
+
suggestions: result.suggestions,
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
ai,
|
|
167
|
+
discussion,
|
|
168
|
+
catalog: {
|
|
169
|
+
all: CATALOG,
|
|
170
|
+
unsupported: UNSUPPORTED,
|
|
171
|
+
adapters: adapterIds(),
|
|
172
|
+
workspace: PLUGIN_ROOT,
|
|
173
|
+
barSizes: supportsBarSize,
|
|
174
|
+
},
|
|
175
|
+
describe: () => ({ ...ai.describe(), routes: ROUTES, tools: TOOL_NAMES }),
|
|
176
|
+
/**
|
|
177
|
+
* Everything the panel's settings screen needs, in one payload.
|
|
178
|
+
*
|
|
179
|
+
* "What can I change here?" was unanswerable: the screen listed four facts
|
|
180
|
+
* with no values and no origin. Each knob below therefore carries its
|
|
181
|
+
* effective value, the config path it came from, and whether the panel can
|
|
182
|
+
* change it at runtime (most cannot — they are row config, and a row is
|
|
183
|
+
* rewritten through `dsh`, not from the browser).
|
|
184
|
+
*/
|
|
185
|
+
settings: () => {
|
|
186
|
+
const resolved = resolveConfig(config).value
|
|
187
|
+
const sourcesOff = Object.entries(resolved.sources).filter(([, on]) => on === false).map(([id]) => id)
|
|
188
|
+
return {
|
|
189
|
+
configPath: 'profiles/web/cordis.patch.yml → id: show-me-data → config',
|
|
190
|
+
ai: {
|
|
191
|
+
...ai.describe(),
|
|
192
|
+
enabled: resolved.ai.enabled,
|
|
193
|
+
maxChars: resolved.ai.maxChars,
|
|
194
|
+
},
|
|
195
|
+
refreshMinutes: resolved.refreshMinutes,
|
|
196
|
+
cacheTtl: resolved.cacheTtl,
|
|
197
|
+
groups: resolved.groups,
|
|
198
|
+
noteworthyLimit: resolved.noteworthyLimit,
|
|
199
|
+
storageDir: resolved.storageDir ?? '(内存,重启即清空)',
|
|
200
|
+
debug: resolved.debug,
|
|
201
|
+
sourcesOff,
|
|
202
|
+
runtime: {
|
|
203
|
+
routes: ROUTES.length,
|
|
204
|
+
tools: TOOL_NAMES.length,
|
|
205
|
+
indicators: CATALOG.length,
|
|
206
|
+
unsupported: UNSUPPORTED.length,
|
|
207
|
+
adapters: adapterIds().length,
|
|
208
|
+
prefix: API_PREFIX,
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** @type {Array<() => void>} */
|
|
215
|
+
const disposers = []
|
|
216
|
+
|
|
217
|
+
const webServer = ctx.get('webServer')
|
|
218
|
+
if (webServer === undefined) {
|
|
219
|
+
log('webServer is unavailable: routes are not registered (tools still work)')
|
|
220
|
+
} else {
|
|
221
|
+
const handler = createRoutes({ useCases, log })
|
|
222
|
+
disposers.push(webServer.register({ kind: 'prefix', path: API_PREFIX, handler }))
|
|
223
|
+
log(`registered ${ROUTES.length} routes under ${API_PREFIX}`)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
disposers.push(registerTools({ tools: ctx.tools, useCases, clock, log }))
|
|
227
|
+
|
|
228
|
+
// Periodic refresh: always through the injected timer so disposal cancels it.
|
|
229
|
+
const timer = ctx.get('timer')
|
|
230
|
+
if (timer !== undefined && resolved.refreshMinutes > 0) {
|
|
231
|
+
disposers.push(
|
|
232
|
+
timer.interval(() => {
|
|
233
|
+
overview.overview({ range: '1Y', force: true, limit: 1 }).catch((error) => log('scheduled refresh failed', { message: error?.message }))
|
|
234
|
+
}, resolved.refreshMinutes * 60_000),
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
ctx.effect?.(() => () => {
|
|
239
|
+
for (const dispose of [...disposers].reverse()) dispose()
|
|
240
|
+
}, 'show-me-data: contributions')
|
|
241
|
+
|
|
242
|
+
// Discussion sessions are real sessions owned by this plugin: they are
|
|
243
|
+
// disposed when the plugin unloads, so a restart never leaves orphans behind.
|
|
244
|
+
ctx.effect?.(() => () => {
|
|
245
|
+
discussion.dispose().catch((error) => log('discussion disposal failed', { message: error?.message }))
|
|
246
|
+
}, 'show-me-data: discussion sessions')
|
|
247
|
+
|
|
248
|
+
log('mounted', {
|
|
249
|
+
routes: webServer === undefined ? 0 : ROUTES.length,
|
|
250
|
+
tools: TOOL_NAMES.length,
|
|
251
|
+
aiMode: gateway.describe().mode,
|
|
252
|
+
indicators: CATALOG.length,
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
return () => {
|
|
256
|
+
for (const dispose of [...disposers].reverse()) dispose()
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Pick the AI gateway for this deployment (docs/06 §1).
|
|
262
|
+
*
|
|
263
|
+
* @param {object} options - options.
|
|
264
|
+
* @param {object} options.ctx - host context.
|
|
265
|
+
* @param {object} options.resolved - resolved configuration.
|
|
266
|
+
* @param {(msg: string, meta?: object) => void} options.log - logger.
|
|
267
|
+
* @returns {object} 'AiGateway'.
|
|
268
|
+
*/
|
|
269
|
+
function buildGateway({ ctx, resolved, log }) {
|
|
270
|
+
const deterministic = createDeterministicGateway()
|
|
271
|
+
if (resolved.ai.enabled === false || resolved.ai.mode === 'deterministic') {
|
|
272
|
+
log('AI gateway: deterministic (disabled by configuration)')
|
|
273
|
+
return deterministic
|
|
274
|
+
}
|
|
275
|
+
const llm = ctx.get('llm')
|
|
276
|
+
if (llm === undefined) {
|
|
277
|
+
log('AI gateway: deterministic (no `llm` service in this deployment)')
|
|
278
|
+
return deterministic
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
const gateway = createDshLlmGateway({
|
|
282
|
+
llm,
|
|
283
|
+
defaultModel: ctx.get('agentDefaultModel'),
|
|
284
|
+
maxChars: resolved.ai.maxChars,
|
|
285
|
+
log,
|
|
286
|
+
})
|
|
287
|
+
log(`AI gateway: llm (${gateway.describe().provider ?? 'unknown'}/${gateway.describe().model ?? 'unknown'})`)
|
|
288
|
+
return gateway
|
|
289
|
+
} catch (error) {
|
|
290
|
+
log('AI gateway: deterministic (llm adapter failed to initialize)', { message: error?.message })
|
|
291
|
+
return deterministic
|
|
292
|
+
}
|
|
293
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-backed ports for the host half (docs/07 T7.3).
|
|
3
|
+
*
|
|
4
|
+
* Both repositories keep a small JSON document and rewrite it atomically
|
|
5
|
+
* (write to a temp file, then rename), so a crash or a concurrent write cannot
|
|
6
|
+
* leave a half-written file behind. A file that does not parse is treated as
|
|
7
|
+
* absent and rebuilt from defaults rather than crashing the plugin.
|
|
8
|
+
*
|
|
9
|
+
* @module host/infra/fs-repos
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync, renameSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
12
|
+
import { dirname } from 'node:path'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Read a JSON file, falling back to a default on any failure.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} file - path.
|
|
18
|
+
* @param {any} fallback - value used when the file is missing or corrupt.
|
|
19
|
+
* @returns {{ value: any, recovered: boolean }} parsed value and whether recovery happened.
|
|
20
|
+
*/
|
|
21
|
+
function readJson(file, fallback) {
|
|
22
|
+
try {
|
|
23
|
+
const text = readFileSync(file, 'utf8')
|
|
24
|
+
if (text.trim() === '') return { value: fallback, recovered: false }
|
|
25
|
+
return { value: JSON.parse(text), recovered: false }
|
|
26
|
+
} catch {
|
|
27
|
+
return { value: fallback, recovered: true }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Write a JSON file atomically.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} file - path.
|
|
35
|
+
* @param {any} value - JSON-serializable value.
|
|
36
|
+
* @returns {void}
|
|
37
|
+
*/
|
|
38
|
+
function writeJson(file, value) {
|
|
39
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
40
|
+
const temp = `${file}.${process.pid}.tmp`
|
|
41
|
+
writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`)
|
|
42
|
+
renameSync(temp, file)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @typedef {Object} FileSnapshotOptions
|
|
47
|
+
* @property {string} file - snapshot store path.
|
|
48
|
+
* @property {number} [maxEntries] - cap on retained entries (oldest evicted first).
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A JSON-file snapshot cache.
|
|
53
|
+
*
|
|
54
|
+
* @param {FileSnapshotOptions} options - options.
|
|
55
|
+
* @returns {import('../../ports/snapshot-repo.js').SnapshotRepository} repository.
|
|
56
|
+
*/
|
|
57
|
+
export function createFileSnapshotRepository({ file, maxEntries = 500 }) {
|
|
58
|
+
/** Serialize writes so concurrent callers cannot interleave a read-modify-write. */
|
|
59
|
+
let queue = Promise.resolve()
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Run one mutation against the current document.
|
|
63
|
+
*
|
|
64
|
+
* @param {(doc: Record<string, any>) => void} mutate - mutation.
|
|
65
|
+
* @returns {Promise<void>} completion.
|
|
66
|
+
*/
|
|
67
|
+
function withDocument(mutate) {
|
|
68
|
+
queue = queue.then(() => {
|
|
69
|
+
const { value } = readJson(file, {})
|
|
70
|
+
const doc = value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
|
71
|
+
mutate(doc)
|
|
72
|
+
const keys = Object.keys(doc)
|
|
73
|
+
if (keys.length > maxEntries) {
|
|
74
|
+
const ordered = keys.sort((a, b) => String(doc[a].storedAt).localeCompare(String(doc[b].storedAt)))
|
|
75
|
+
for (const key of ordered.slice(0, keys.length - maxEntries)) delete doc[key]
|
|
76
|
+
}
|
|
77
|
+
writeJson(file, doc)
|
|
78
|
+
})
|
|
79
|
+
return queue
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
async read(key) {
|
|
84
|
+
await queue
|
|
85
|
+
const { value } = readJson(file, {})
|
|
86
|
+
const entry = value?.[key]
|
|
87
|
+
if (entry === undefined) return undefined
|
|
88
|
+
return { payload: structuredClone(entry.payload), storedAt: entry.storedAt, ttlMs: entry.ttlMs }
|
|
89
|
+
},
|
|
90
|
+
async write(key, payload, { ttlMs, at }) {
|
|
91
|
+
await withDocument((doc) => {
|
|
92
|
+
doc[key] = { payload: structuredClone(payload), storedAt: at, ttlMs }
|
|
93
|
+
})
|
|
94
|
+
},
|
|
95
|
+
async list() {
|
|
96
|
+
await queue
|
|
97
|
+
const { value } = readJson(file, {})
|
|
98
|
+
return Object.entries(value ?? {}).map(([key, entry]) => ({ key, storedAt: entry.storedAt, ttlMs: entry.ttlMs }))
|
|
99
|
+
},
|
|
100
|
+
async clear() {
|
|
101
|
+
await withDocument((doc) => {
|
|
102
|
+
for (const key of Object.keys(doc)) delete doc[key]
|
|
103
|
+
})
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @typedef {Object} FileWatchlistOptions
|
|
110
|
+
* @property {string} file - watchlist path.
|
|
111
|
+
*/
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A JSON-file watchlist.
|
|
115
|
+
*
|
|
116
|
+
* @param {FileWatchlistOptions} options - options.
|
|
117
|
+
* @returns {import('../../ports/snapshot-repo.js').WatchlistRepository} repository.
|
|
118
|
+
*/
|
|
119
|
+
export function createFileWatchlistRepository({ file }) {
|
|
120
|
+
let queue = Promise.resolve()
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Read the current item list.
|
|
124
|
+
*
|
|
125
|
+
* @returns {object[]} items.
|
|
126
|
+
*/
|
|
127
|
+
function readItems() {
|
|
128
|
+
const { value } = readJson(file, [])
|
|
129
|
+
return Array.isArray(value) ? value : []
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Run one mutation against the item list.
|
|
134
|
+
*
|
|
135
|
+
* @param {(items: object[]) => any} mutate - mutation returning a value.
|
|
136
|
+
* @returns {Promise<any>} mutation result.
|
|
137
|
+
*/
|
|
138
|
+
function withItems(mutate) {
|
|
139
|
+
let result
|
|
140
|
+
queue = queue.then(() => {
|
|
141
|
+
const items = readItems()
|
|
142
|
+
result = mutate(items)
|
|
143
|
+
writeJson(file, items)
|
|
144
|
+
})
|
|
145
|
+
return queue.then(() => result)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
async list() {
|
|
150
|
+
await queue
|
|
151
|
+
return structuredClone(readItems())
|
|
152
|
+
},
|
|
153
|
+
async add(item) {
|
|
154
|
+
return withItems((items) => {
|
|
155
|
+
const existing = items.find((entry) => entry.indicatorId === item.indicatorId)
|
|
156
|
+
if (existing !== undefined) return { item: structuredClone(existing), created: false }
|
|
157
|
+
const stored = structuredClone(item)
|
|
158
|
+
items.push(stored)
|
|
159
|
+
return { item: structuredClone(stored), created: true }
|
|
160
|
+
})
|
|
161
|
+
},
|
|
162
|
+
async remove(indicatorId) {
|
|
163
|
+
return withItems((items) => {
|
|
164
|
+
const index = items.findIndex((entry) => entry.indicatorId === indicatorId)
|
|
165
|
+
if (index === -1) return 'notFound'
|
|
166
|
+
items.splice(index, 1)
|
|
167
|
+
return 'removed'
|
|
168
|
+
})
|
|
169
|
+
},
|
|
170
|
+
async update(indicatorId, patch) {
|
|
171
|
+
return withItems((items) => {
|
|
172
|
+
const index = items.findIndex((entry) => entry.indicatorId === indicatorId)
|
|
173
|
+
if (index === -1) return undefined
|
|
174
|
+
items[index] = { ...items[index], ...structuredClone(patch), indicatorId }
|
|
175
|
+
return structuredClone(items[index])
|
|
176
|
+
})
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory port implementations used when the host has no writable storage
|
|
3
|
+
* (docs/02 §3.2). Same contracts as the file-backed ones, so behaviour does not
|
|
4
|
+
* change when a deployment adds a storage directory.
|
|
5
|
+
*
|
|
6
|
+
* @module host/infra/memory-fallback
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* In-memory snapshot cache.
|
|
11
|
+
*
|
|
12
|
+
* @returns {import('../../ports/snapshot-repo.js').SnapshotRepository} repository.
|
|
13
|
+
*/
|
|
14
|
+
export function memorySnapshotRepository() {
|
|
15
|
+
/** @type {Map<string, { payload: any, storedAt: string, ttlMs: number }>} */
|
|
16
|
+
const store = new Map()
|
|
17
|
+
return {
|
|
18
|
+
async read(key) {
|
|
19
|
+
const entry = store.get(key)
|
|
20
|
+
return entry === undefined ? undefined : { payload: structuredClone(entry.payload), storedAt: entry.storedAt, ttlMs: entry.ttlMs }
|
|
21
|
+
},
|
|
22
|
+
async write(key, payload, { ttlMs, at }) {
|
|
23
|
+
store.set(key, { payload: structuredClone(payload), storedAt: at, ttlMs })
|
|
24
|
+
},
|
|
25
|
+
async list() {
|
|
26
|
+
return [...store.entries()].map(([key, entry]) => ({ key, storedAt: entry.storedAt, ttlMs: entry.ttlMs }))
|
|
27
|
+
},
|
|
28
|
+
async clear() {
|
|
29
|
+
store.clear()
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* In-memory watchlist.
|
|
36
|
+
*
|
|
37
|
+
* @returns {import('../../ports/snapshot-repo.js').WatchlistRepository} repository.
|
|
38
|
+
*/
|
|
39
|
+
export function memoryWatchlistRepository() {
|
|
40
|
+
/** @type {Map<string, any>} */
|
|
41
|
+
const items = new Map()
|
|
42
|
+
return {
|
|
43
|
+
async list() {
|
|
44
|
+
return [...items.values()].map((item) => structuredClone(item))
|
|
45
|
+
},
|
|
46
|
+
async add(item) {
|
|
47
|
+
const existing = items.get(item.indicatorId)
|
|
48
|
+
if (existing !== undefined) return { item: structuredClone(existing), created: false }
|
|
49
|
+
const stored = structuredClone(item)
|
|
50
|
+
items.set(stored.indicatorId, stored)
|
|
51
|
+
return { item: structuredClone(stored), created: true }
|
|
52
|
+
},
|
|
53
|
+
async remove(indicatorId) {
|
|
54
|
+
return items.delete(indicatorId) ? 'removed' : 'notFound'
|
|
55
|
+
},
|
|
56
|
+
async update(indicatorId, patch) {
|
|
57
|
+
const existing = items.get(indicatorId)
|
|
58
|
+
if (existing === undefined) return undefined
|
|
59
|
+
const next = { ...existing, ...structuredClone(patch), indicatorId }
|
|
60
|
+
items.set(indicatorId, next)
|
|
61
|
+
return structuredClone(next)
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}
|