@dshtrading/watchlist 0.6.0 → 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.
Files changed (2) hide show
  1. package/lib/file-store.js +68 -56
  2. 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 { transactStore } from "@dshtrading/dsh-home";
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
- * 写路径经 `@dshtrading/dsh-home` 的 {@link transactStore}——跨进程排他锁 + 锁内
10
- * 新鲜读盘 + 原子写(2026-10-09 多进程互相覆盖事故修复):桌面端与 CLI 各写一行时
11
- * 双方都留在盘上,不再互相抹掉(此前是内存缓存 + 整表回写)。进程内的 enqueue 串行化
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
- /** 新鲜读盘(坏 JSON/非对象按空表);供 load 与锁内重读共用。 */
24
- async function readFromDisk() {
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 content = await readFile(filePath, "utf8");
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
- if (err?.code !== "ENOENT") console.error(`[dsh-trading/watchlist] failed to read watchlists from ${filePath}:`, err);
33
+ console.error(READ_FAILED, err);
31
34
  return {};
32
35
  }
33
36
  }
34
- async function load() {
35
- if (cache !== null) return cache;
36
- cache = await readFromDisk();
37
- return cache;
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
- cache = await transactStore(filePath, readFromDisk, () => ({ ...next }), (map) => map, LOG_PREFIX);
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
- cache = await transactStore(filePath, readFromDisk, (onDisk) => {
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
- }, (map) => map, LOG_PREFIX);
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
- cache = await transactStore(filePath, readFromDisk, (onDisk) => {
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
- }, (map) => map, LOG_PREFIX);
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
- cache = await transactStore(filePath, readFromDisk, (onDisk) => {
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
- }, (map) => map, LOG_PREFIX);
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
- cache = await transactStore(filePath, readFromDisk, (onDisk) => {
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
- }, (map) => map, LOG_PREFIX);
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
- /** 新鲜读盘(容器包裹;坏形过滤);供 load 与锁内重读共用。 */
152
- async function readFromDisk() {
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 content = await readFile(filePath, "utf8");
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
- if (err?.code !== "ENOENT") console.error(`[dsh-trading/watchlist] failed to read watchlist groups from ${filePath}:`, err);
164
+ console.error(READ_FAILED, err);
159
165
  return [];
160
166
  }
161
167
  }
162
- async function load() {
163
- if (cache !== null) return cache;
164
- cache = await readFromDisk();
165
- return cache;
166
- }
167
- const serialize = (groups) => ({ groups });
168
- /** 跨进程读改写;返回 null 表示无变化(不落盘),缓存仍取锁内新鲜值。 */
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
- cache = await transactStore(filePath, readFromDisk, (onDisk) => mutate(onDisk) ?? onDisk, serialize, LOG_PREFIX);
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
- /** 新鲜读盘(坏形按空选中);供 load 与锁内重读共用。 */
244
- async function readFromDisk() {
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 content = await readFile(filePath, "utf8");
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
- if (err?.code !== "ENOENT") console.error(`[dsh-trading/watchlist] failed to read selection from ${filePath}:`, err);
260
+ console.error(READ_FAILED, err);
251
261
  return { instrument: null };
252
262
  }
253
263
  }
254
- async function load() {
255
- if (cache !== null) return cache;
256
- cache = await readFromDisk();
257
- return cache;
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
- cache = await transactStore(filePath, readFromDisk, () => ({ instrument: record.instrument === null ? null : { ...record.instrument } }), (value) => value, LOG_PREFIX);
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.0",
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/api": "^0.6.0",
25
- "@dshtrading/dsh-home": "^0.6.0"
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.0-rc.2"
29
+ "@deepseek-ai/dsh-tools": ">=0.2.1-rc.2"
30
30
  },
31
31
  "devDependencies": {
32
32
  "tsdown": "^0.22.0",