@dshtrading/watchlist 0.6.1 → 0.6.2
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/lib/file-store.js +68 -56
- package/package.json +4 -4
package/lib/file-store.js
CHANGED
|
@@ -1,54 +1,59 @@
|
|
|
1
1
|
import { WATCHLIST_SEEDS } from "./seeds.js";
|
|
2
2
|
import { newWatchlistGroupId, normalizeWatchlistRow } from "./index.js";
|
|
3
|
-
import {
|
|
4
|
-
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { createRecordStoreAdapter, readStoreText } from "@dshtrading/dsh-home";
|
|
5
4
|
//#region src/file-store.ts
|
|
6
5
|
/**
|
|
7
6
|
* 文件持久化版自选/选中 store(Node.js 宿主侧使用,issue #32 规格)。
|
|
8
7
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* 读写骨架经 `@dshtrading/dsh-home` 的 {@link createRecordStoreAdapter}——跨进程排他锁 +
|
|
9
|
+
* 锁内新鲜读盘 + 原子写 + 落盘成功后才发布缓存(2026-10-09 多进程互相覆盖事故修复):
|
|
10
|
+
* 桌面端与 CLI 各写一行时双方都留在盘上,不再互相抹掉(此前是内存缓存 + 整表回写)。
|
|
11
|
+
* 进程内的 enqueue 串行化保留(它保证同一进程内的读改写顺序),跨进程部分由锁承担。
|
|
12
|
+
*
|
|
13
|
+
* 本文件是「多文件形态」的自选域:成员关系(本文件)、分组注册表(容器包裹)与选中
|
|
14
|
+
* 记录(非整表对象)各建一个适配器,读写形态互不共享。
|
|
13
15
|
*/
|
|
14
16
|
const LOG_PREFIX = "[dsh-trading/watchlist] failed to atomic flush watchlists to";
|
|
15
17
|
function createFileWatchlistStore(filePath) {
|
|
16
|
-
let cache = null;
|
|
17
18
|
let pendingWrite = Promise.resolve();
|
|
18
19
|
const enqueue = (fn) => {
|
|
19
20
|
const next = pendingWrite.then(fn, fn);
|
|
20
21
|
pendingWrite = next.then(() => {}, () => {});
|
|
21
22
|
return next;
|
|
22
23
|
};
|
|
23
|
-
/**
|
|
24
|
-
|
|
24
|
+
/** 读盘/解析失败的日志前缀(坏 JSON 与非 ENOENT 读失败同一条,与既有 load 口径逐字一致)。 */
|
|
25
|
+
const READ_FAILED = `[dsh-trading/watchlist] failed to read watchlists from ${filePath}:`;
|
|
26
|
+
/** 文本 → 市场表:坏 JSON / 非对象降级空表,其余(含数组)静默丢弃(不外包)。 */
|
|
27
|
+
function decode(text) {
|
|
28
|
+
if (text === void 0) return {};
|
|
25
29
|
try {
|
|
26
|
-
const
|
|
27
|
-
const parsed = JSON.parse(content);
|
|
30
|
+
const parsed = JSON.parse(text);
|
|
28
31
|
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
29
32
|
} catch (err) {
|
|
30
|
-
|
|
33
|
+
console.error(READ_FAILED, err);
|
|
31
34
|
return {};
|
|
32
35
|
}
|
|
33
36
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
const store = createRecordStoreAdapter({
|
|
38
|
+
filePath,
|
|
39
|
+
logPrefix: LOG_PREFIX,
|
|
40
|
+
readFromDisk: () => readStoreText(filePath, READ_FAILED),
|
|
41
|
+
decode,
|
|
42
|
+
encode: (map) => map
|
|
43
|
+
});
|
|
39
44
|
return {
|
|
40
45
|
async list() {
|
|
41
|
-
return { ...await load() };
|
|
46
|
+
return { ...await store.load() };
|
|
42
47
|
},
|
|
43
48
|
async save(next) {
|
|
44
49
|
await enqueue(async () => {
|
|
45
|
-
|
|
50
|
+
await store.commit(() => ({ ...next }));
|
|
46
51
|
});
|
|
47
52
|
},
|
|
48
53
|
async add(market, instrument) {
|
|
49
54
|
let added = false;
|
|
50
55
|
await enqueue(async () => {
|
|
51
|
-
|
|
56
|
+
await store.commit((onDisk) => {
|
|
52
57
|
const rows = onDisk[market] ?? [];
|
|
53
58
|
if (rows.some((row) => row.symbol === instrument.symbol)) return onDisk;
|
|
54
59
|
added = true;
|
|
@@ -56,14 +61,14 @@ function createFileWatchlistStore(filePath) {
|
|
|
56
61
|
...onDisk,
|
|
57
62
|
[market]: [...rows, normalizeWatchlistRow(instrument)]
|
|
58
63
|
};
|
|
59
|
-
}
|
|
64
|
+
});
|
|
60
65
|
});
|
|
61
66
|
return added;
|
|
62
67
|
},
|
|
63
68
|
async remove(market, symbol) {
|
|
64
69
|
let removed = false;
|
|
65
70
|
await enqueue(async () => {
|
|
66
|
-
|
|
71
|
+
await store.commit((onDisk) => {
|
|
67
72
|
const existing = onDisk[market];
|
|
68
73
|
const rows = Array.isArray(existing) ? existing : WATCHLIST_SEEDS[market] ?? [];
|
|
69
74
|
const nextRows = rows.filter((row) => row.symbol !== symbol);
|
|
@@ -73,14 +78,14 @@ function createFileWatchlistStore(filePath) {
|
|
|
73
78
|
...onDisk,
|
|
74
79
|
[market]: nextRows
|
|
75
80
|
};
|
|
76
|
-
}
|
|
81
|
+
});
|
|
77
82
|
});
|
|
78
83
|
return removed;
|
|
79
84
|
},
|
|
80
85
|
async assignGroup(market, symbol, groupId, member) {
|
|
81
86
|
let assigned = false;
|
|
82
87
|
await enqueue(async () => {
|
|
83
|
-
|
|
88
|
+
await store.commit((onDisk) => {
|
|
84
89
|
const rows = onDisk[market];
|
|
85
90
|
if (!Array.isArray(rows)) return onDisk;
|
|
86
91
|
const index = rows.findIndex((row) => row.symbol === symbol);
|
|
@@ -104,14 +109,14 @@ function createFileWatchlistStore(filePath) {
|
|
|
104
109
|
...onDisk,
|
|
105
110
|
[market]: nextRows
|
|
106
111
|
};
|
|
107
|
-
}
|
|
112
|
+
});
|
|
108
113
|
});
|
|
109
114
|
return assigned;
|
|
110
115
|
},
|
|
111
116
|
async stripGroup(groupId) {
|
|
112
117
|
let cleaned = 0;
|
|
113
118
|
await enqueue(async () => {
|
|
114
|
-
|
|
119
|
+
await store.commit((onDisk) => {
|
|
115
120
|
const next = {};
|
|
116
121
|
for (const [market, rows] of Object.entries(onDisk)) {
|
|
117
122
|
if (!Array.isArray(rows)) continue;
|
|
@@ -134,45 +139,49 @@ function createFileWatchlistStore(filePath) {
|
|
|
134
139
|
next[market] = changed ? nextRows : rows;
|
|
135
140
|
}
|
|
136
141
|
return cleaned > 0 ? next : onDisk;
|
|
137
|
-
}
|
|
142
|
+
});
|
|
138
143
|
});
|
|
139
144
|
return cleaned;
|
|
140
145
|
}
|
|
141
146
|
};
|
|
142
147
|
}
|
|
143
148
|
function createFileWatchlistGroupsStore(filePath) {
|
|
144
|
-
let cache = null;
|
|
145
149
|
let pendingWrite = Promise.resolve();
|
|
146
150
|
const enqueue = (fn) => {
|
|
147
151
|
const next = pendingWrite.then(fn, fn);
|
|
148
152
|
pendingWrite = next.then(() => {}, () => {});
|
|
149
153
|
return next;
|
|
150
154
|
};
|
|
151
|
-
/**
|
|
152
|
-
|
|
155
|
+
/** 读盘/解析失败的日志前缀(坏 JSON 与非 ENOENT 读失败同一条,与既有 load 口径逐字一致)。 */
|
|
156
|
+
const READ_FAILED = `[dsh-trading/watchlist] failed to read watchlist groups from ${filePath}:`;
|
|
157
|
+
/** 文本 → 分组表:容器包裹(`{ groups: [] }`)逐条过滤形状;坏 JSON 降级空表并打日志。 */
|
|
158
|
+
function decode(text) {
|
|
159
|
+
if (text === void 0) return [];
|
|
153
160
|
try {
|
|
154
|
-
const
|
|
155
|
-
const parsed = JSON.parse(content);
|
|
161
|
+
const parsed = JSON.parse(text);
|
|
156
162
|
return Array.isArray(parsed.groups) ? parsed.groups.filter((group) => typeof group === "object" && group !== null && typeof group.id === "string" && group.id !== "" && typeof group.name === "string") : [];
|
|
157
163
|
} catch (err) {
|
|
158
|
-
|
|
164
|
+
console.error(READ_FAILED, err);
|
|
159
165
|
return [];
|
|
160
166
|
}
|
|
161
167
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
168
|
+
const store = createRecordStoreAdapter({
|
|
169
|
+
filePath,
|
|
170
|
+
logPrefix: LOG_PREFIX,
|
|
171
|
+
readFromDisk: () => readStoreText(filePath, READ_FAILED),
|
|
172
|
+
decode,
|
|
173
|
+
encode: (groups) => ({ groups })
|
|
174
|
+
});
|
|
175
|
+
/**
|
|
176
|
+
* 跨进程读改写;mutate 返回 null 表示无变化(不落盘),缓存仍取锁内新鲜值。
|
|
177
|
+
* 返回 null 是分组工具的既有写法,逐字保留。
|
|
178
|
+
*/
|
|
169
179
|
async function commit(mutate) {
|
|
170
|
-
|
|
171
|
-
return cache;
|
|
180
|
+
return store.commit((onDisk) => mutate(onDisk) ?? onDisk);
|
|
172
181
|
}
|
|
173
182
|
return {
|
|
174
183
|
async list() {
|
|
175
|
-
return [...await load()];
|
|
184
|
+
return [...await store.load()];
|
|
176
185
|
},
|
|
177
186
|
async create(name) {
|
|
178
187
|
let result;
|
|
@@ -233,37 +242,40 @@ function createFileWatchlistGroupsStore(filePath) {
|
|
|
233
242
|
};
|
|
234
243
|
}
|
|
235
244
|
function createFileSelectionStore(filePath) {
|
|
236
|
-
let cache = null;
|
|
237
245
|
let pendingWrite = Promise.resolve();
|
|
238
246
|
const enqueue = (fn) => {
|
|
239
247
|
const next = pendingWrite.then(fn, fn);
|
|
240
248
|
pendingWrite = next.then(() => {}, () => {});
|
|
241
249
|
return next;
|
|
242
250
|
};
|
|
243
|
-
/**
|
|
244
|
-
|
|
251
|
+
/** 读盘/解析失败的日志前缀(坏 JSON 与非 ENOENT 读失败同一条,与既有 load 口径逐字一致)。 */
|
|
252
|
+
const READ_FAILED = `[dsh-trading/watchlist] failed to read selection from ${filePath}:`;
|
|
253
|
+
/** 文本 → 选中记录:坏形按空选中;只取 instrument 一个键(其它键不落回)。 */
|
|
254
|
+
function decode(text) {
|
|
255
|
+
if (text === void 0) return { instrument: null };
|
|
245
256
|
try {
|
|
246
|
-
const
|
|
247
|
-
const parsed = JSON.parse(content);
|
|
257
|
+
const parsed = JSON.parse(text);
|
|
248
258
|
return parsed !== null && typeof parsed === "object" ? { instrument: parsed.instrument ?? null } : { instrument: null };
|
|
249
259
|
} catch (err) {
|
|
250
|
-
|
|
260
|
+
console.error(READ_FAILED, err);
|
|
251
261
|
return { instrument: null };
|
|
252
262
|
}
|
|
253
263
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
264
|
+
const store = createRecordStoreAdapter({
|
|
265
|
+
filePath,
|
|
266
|
+
logPrefix: LOG_PREFIX,
|
|
267
|
+
readFromDisk: () => readStoreText(filePath, READ_FAILED),
|
|
268
|
+
decode,
|
|
269
|
+
encode: (value) => value
|
|
270
|
+
});
|
|
259
271
|
return {
|
|
260
272
|
async get() {
|
|
261
|
-
const record = await load();
|
|
273
|
+
const record = await store.load();
|
|
262
274
|
return { instrument: record.instrument === null ? null : { ...record.instrument } };
|
|
263
275
|
},
|
|
264
276
|
async set(record) {
|
|
265
277
|
await enqueue(async () => {
|
|
266
|
-
|
|
278
|
+
await store.commit(() => ({ instrument: record.instrument === null ? null : { ...record.instrument } }));
|
|
267
279
|
});
|
|
268
280
|
}
|
|
269
281
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dshtrading/watchlist",
|
|
3
3
|
"description": "Host-side watchlist + selection stores for dsh-trading: multi-list file store (~/.dsh/watchlists.json), selection store (~/.dsh/selection.json), and cross-market watchlist_* tools (watchlist_list/add/remove/select) on the host plane",
|
|
4
|
-
"version": "0.6.
|
|
4
|
+
"version": "0.6.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "./lib/index.d.ts",
|
|
@@ -21,12 +21,12 @@
|
|
|
21
21
|
],
|
|
22
22
|
"license": "PolyForm-Noncommercial-1.0.0",
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@dshtrading/
|
|
25
|
-
"@dshtrading/
|
|
24
|
+
"@dshtrading/dsh-home": "^0.6.2",
|
|
25
|
+
"@dshtrading/api": "^0.6.2"
|
|
26
26
|
},
|
|
27
27
|
"peerDependencies": {
|
|
28
28
|
"@deepseek-ai/cordis": ">=4.0.0",
|
|
29
|
-
"@deepseek-ai/dsh-tools": ">=0.2.
|
|
29
|
+
"@deepseek-ai/dsh-tools": ">=0.2.1-rc.2"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"tsdown": "^0.22.0",
|