nx-kv 0.1.1 → 0.2.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.
@@ -1,9 +1,9 @@
1
1
  // 清单 service —— 四把 key 上的读-改-写。
2
2
  //
3
- // ─── 必须知道的四条契约(来自 Flutter 端 todo_submit_service.dart 的文件头注释)───
3
+ // ─── 必须知道的五条契约(前四条来自 Flutter 端 todo_submit_service.dart 的文件头注释)───
4
4
  //
5
5
  // 1. **id 分配必须扫「待办 + 冻结」**。冻结任务保留原 id,只扫待办的话,
6
- // 待办清空后会从 1 重新分配,与冻结任务撞车 —— 解冻时无法按 id 定位到正确任务。
6
+ // 待办清空后会从 1 重新分配,与冻结任务撞车。
7
7
  // 实测佐证:todo:open 为空、todo:freeze 有 id 2/10/17/23/24/28,只扫 open 会分配 1。
8
8
  //
9
9
  // 2. **topic 不在快捷列表时,要连 `todo:topics` 一起写**,否则它不会出现在候选里。
@@ -13,9 +13,9 @@
13
13
  // 顺序是刻意的 —— open 先于 topics:万一 topics 写失败,任务本身不丢,
14
14
  // 只是短期内不在快捷候选里;反过来则会丢失任务。
15
15
  //
16
- // 4. **写前重读**。后端没有「单条更新」接口,所有写都是整把 key 覆盖。
17
- // 不重读就会把并发写(本机 CLI / App / 面板同时操作)直接覆盖掉。
18
- // 接受毫秒级竞态 —— 与 Flutter 端同策略。
16
+ // 5. **定位一律按内容(文本 ref),不按 id**。id 只是给人看的元数据——
17
+ // 它既会被复用(分配只扫 open + freeze),同一主题里也本来就有重复内容。
18
+ // 详见下方「定位」一节。
19
19
  //
20
20
  // ─── 时间格式(沿用既有数据的约定,不要统一)───
21
21
  // createdAt / frozenAt:Dart `toIso8601String()` 风格,本地时间无时区
@@ -147,51 +147,64 @@ export function nextTaskId(open, freeze) {
147
147
  return max + 1;
148
148
  }
149
149
 
150
- // ─── 定位:id 不是唯一键 ⚠️ ────────────────────────────────────────
150
+ // ─── 定位:一律按**内容**,不按 id ⚠️ ──────────────────────────────
151
151
  //
152
- // **done 里会积累同 id 的条目**:id 分配只扫 open + freeze(契约 1),
153
- // 所以任务完成、open 清空之后,新任务会重新用上已被完成任务占过的 id。
154
- // 实测这份数据里 done 有 68 条、其中 id=29 有 7 条。
152
+ // **id 非常容易重复,不足以定位一条任务。** 两条独立的原因:
155
153
  //
156
- // 由此推出两条硬约束,违反其一都会静默删错数据:
154
+ // 1. 分配只扫 open + freeze(契约 1),任务完成、open 清空后新任务会重新
155
+ // 用上已占过的 id。实测 done 有 68 条、其中 id=29 有 7 条。
156
+ // 2. 同一个主题里,写「修复登录页」这样内容的任务本来就会反复出现——
157
+ // 即使 id 不撞,光看 id 也说不清调用方指的是哪一条。
158
+ //
159
+ // 所以 `id` 在本项目里**只是给人和外部系统看的元数据**:继续分配、继续出现在
160
+ // 输出里,但**不再是任何命令的入参**。定位一律用文本内容,见下面的 ref。
161
+ //
162
+ // 由此推出两条硬约束,违反其一都会静默改错数据:
157
163
  //
158
164
  // 1. **命中多条时必须消歧,不能猜一条。** 用 `findIndex` 取第一条,
159
165
  // 在 done 上取到的是最老的记录,而调用方往往以为自己在操作刚创建的那条。
160
- // 2. **变更必须按 index 定位,禁止 `filter(t => t.id !== id)`。**
161
- // 后者会把**所有**同 id 的条目一起删掉——曾因此在真实数据上误删 7 条。
166
+ // 2. **变更必须按 index 定位,禁止 `filter(t => t.text !== ref)`。**
167
+ // 后者会把**所有**同内容的条目一起删掉。
162
168
 
163
- /** 找出所有匹配 id 的条目(跨桶,含同桶内重复) */
164
- export function locateAll(all, id) {
169
+ /** 找出所有内容匹配的条目(跨桶,含同桶内重复)。
170
+ * 用整串精确比对——不做前缀/模糊匹配,避免「看起来像」被当成「就是它」。 */
171
+ export function locateAll(all, ref) {
172
+ const want = String(ref ?? '');
165
173
  const hits = [];
166
174
  for (const bucket of BUCKETS) {
167
175
  all[bucket].forEach((task, index) => {
168
- if (task.id === id) hits.push({ bucket, index, task });
176
+ if (task.text === want) hits.push({ bucket, index, task });
169
177
  });
170
178
  }
171
179
  return hits;
172
180
  }
173
181
 
174
182
  /**
175
- * 定位唯一一条。
183
+ * 定位唯一一条。`ref` 是任务**内容**(整串)。
176
184
  *
177
185
  * 消歧手段有两个,按需组合:
178
- * `--topic <名字>` 按主题收窄(当同 id 的条目分属不同主题时够用)
179
- * `--pick <n>` 按编号选择(同 id 且同主题时唯一可靠的定位方式)
186
+ * `--topic <名字>` 按主题收窄(同内容散落在不同主题时的快捷方式)
187
+ * `--pick <n>` 按编号选择(最通用;同内容且同主题时唯一可靠的手段)
180
188
  *
181
- * `--pick` 是必需的:实测这份数据里 id=29 的 7 条**全部是 qus 主题**,
182
- * 光靠 --topic 永远收敛不到一条。报错时把候选项编号列出来,
183
- * 调用方(人或 agent)照着选即可——**绝不替调用方猜**。
189
+ * 报错时把候选项编号列出来,调用方(人或 agent)照着选即可——**绝不替调用方猜**。
184
190
  */
185
- export function locateUnique(all, id, { topic, pick } = {}) {
186
- const allHits = locateAll(all, id);
191
+ export function locateUnique(all, ref, { topic, pick } = {}) {
192
+ const want = String(ref ?? '');
193
+
194
+ // 空 ref 是**参数错误**而不是 NOT_FOUND:绝大多数时候是忘了传 --ref,
195
+ // 而空串恰好会匹配上数据里 text 为空的坏条目——那等于随机改一条,必须挡在门外。
196
+ if (!want) throw badInput('task 内容不能为空(用 --ref 指定要操作的任务文本)');
197
+
198
+ const allHits = locateAll(all, want);
187
199
  const hits = topic ? allHits.filter((h) => h.task.topic === topic) : allHits;
200
+ const shown = want.length > 40 ? want.slice(0, 40) + '…' : want;
188
201
 
189
202
  if (!hits.length) {
190
203
  if (allHits.length) {
191
204
  const topics = [...new Set(allHits.map((h) => h.task.topic))].join(', ');
192
- throw notFound(`id=${id} 存在,但 topic 不是 ${topic}(实际 topic: ${topics})`);
205
+ throw notFound(`task「${shown}」存在,但 topic 不是 ${topic}(实际 topic: ${topics})`);
193
206
  }
194
- throw notFound(`task id=${id} 不存在(待办 / 已完成 / 冻结里都没有)`);
207
+ throw notFound(`task「${shown}」不存在(待办 / 已完成 / 冻结里都没有)`);
195
208
  }
196
209
 
197
210
  if (pick !== undefined && pick !== null) {
@@ -204,12 +217,12 @@ export function locateUnique(all, id, { topic, pick } = {}) {
204
217
 
205
218
  if (hits.length > 1) {
206
219
  const list = hits
207
- .map((h, i) => ` [${i}] ${h.bucket.padEnd(6)} ${h.task.topic.padEnd(5)} ${h.task.createdAt} ${h.task.text.slice(0, 50)}`)
220
+ .map((h, i) => ` [${i}] ${h.bucket.padEnd(6)} ${h.task.topic.padEnd(5)} ${h.task.createdAt}`)
208
221
  .join('\n');
209
222
  throw conflict(
210
- `id=${id} 命中 ${hits.length} 条,无法确定是哪一条。用 --pick <n> 选择:\n${list}` +
223
+ `task「${shown}」命中 ${hits.length} 条,无法确定是哪一条。用 --pick <n> 选择:\n${list}` +
211
224
  (topic ? '' : `\n(也可先用 --topic <名字> 收窄)`),
212
- { candidates: hits.map((h) => ({ bucket: h.bucket, topic: h.task.topic, createdAt: h.task.createdAt, text: h.task.text })) }
225
+ { candidates: hits.map((h, i) => ({ pick: i, bucket: h.bucket, topic: h.task.topic, createdAt: h.task.createdAt })) }
213
226
  );
214
227
  }
215
228
  return hits[0];
@@ -257,9 +270,9 @@ export async function listTodos({ topic, status, groupId } = {}) {
257
270
  };
258
271
  }
259
272
 
260
- export async function getTodo(id, { topic, pick, groupId } = {}) {
273
+ export async function getTodo(ref, { topic, pick, groupId } = {}) {
261
274
  const all = await loadAll(groupId);
262
- const hit = locateUnique(all, id, { topic, pick });
275
+ const hit = locateUnique(all, ref, { topic, pick });
263
276
  return { ...hit.task, bucket: hit.bucket };
264
277
  }
265
278
 
@@ -302,11 +315,12 @@ export async function addTodo({ topic, text, groupId }) {
302
315
  }
303
316
 
304
317
  /** 编辑任务:只改传入的字段(PATCH 语义),未传的保持原值。
318
+ * 定位用 `ref`(当前内容);`--text` 才是要改成的新内容。
305
319
  * `--match-topic` 是用来**消歧**的定位条件,与要改的 `--topic` 是两回事——
306
320
  * 合并成一个会让「把 qus 改成 go」这种操作无从表达。 */
307
- export async function updateTodo(id, { topic, text, note, matchTopic, pick, groupId } = {}) {
321
+ export async function updateTodo(ref, { topic, text, note, matchTopic, pick, groupId } = {}) {
308
322
  const all = await loadAll(groupId);
309
- const hit = locateUnique(all, id, { topic: matchTopic, pick });
323
+ const hit = locateUnique(all, ref, { topic: matchTopic, pick });
310
324
 
311
325
  if (topic === undefined && text === undefined && note === undefined) {
312
326
  throw badInput('至少要给出一个要改的字段(--topic / --text / --note)');
@@ -338,10 +352,10 @@ export async function updateTodo(id, { topic, text, note, matchTopic, pick, grou
338
352
  return { task: next, bucket: hit.bucket, topicAdded };
339
353
  }
340
354
 
341
- /** 删除任务。只移除**命中的那一条**(按 index),不按 id 批量删。 */
342
- export async function removeTodo(id, { topic, pick, groupId } = {}) {
355
+ /** 删除任务。只移除**命中的那一条**(按 index),不按内容批量删。 */
356
+ export async function removeTodo(ref, { topic, pick, groupId } = {}) {
343
357
  const all = await loadAll(groupId);
344
- const hit = locateUnique(all, id, { topic, pick });
358
+ const hit = locateUnique(all, ref, { topic, pick });
345
359
 
346
360
  const bucket = [...all[hit.bucket]];
347
361
  bucket.splice(hit.index, 1);
@@ -350,11 +364,11 @@ export async function removeTodo(id, { topic, pick, groupId } = {}) {
350
364
  }
351
365
 
352
366
  /** 标记完成:待办 → 已完成,补 doneAt 与 note */
353
- export async function doneTodo(id, { result, topic, pick, groupId } = {}) {
367
+ export async function doneTodo(ref, { result, topic, pick, groupId } = {}) {
354
368
  const all = await loadAll(groupId);
355
- const hit = locateUnique(all, id, { topic, pick });
356
- if (hit.bucket === 'done') throw conflict(`task id=${id} 已经是完成状态`);
357
- if (hit.bucket === 'freeze') throw conflict(`task id=${id} 处于冻结中,先解冻再完成`);
369
+ const hit = locateUnique(all, ref, { topic, pick });
370
+ if (hit.bucket === 'done') throw conflict(`task「${hit.task.text}」已经是完成状态`);
371
+ if (hit.bucket === 'freeze') throw conflict(`task「${hit.task.text}」处于冻结中,先解冻再完成`);
358
372
 
359
373
  const finished = {
360
374
  ...hit.task,
@@ -362,7 +376,7 @@ export async function doneTodo(id, { result, topic, pick, groupId } = {}) {
362
376
  note: result === undefined ? hit.task.note : String(result),
363
377
  };
364
378
 
365
- // 按 index 移除,再追加到 done —— 不能用 filter(id !== id),那会连带删掉同 id 的其他条目
379
+ // 按 index 移除,再追加到 done —— 不能用 filter(text !== ref),那会连带删掉同内容的其他条目
366
380
  const from = [...all[hit.bucket]];
367
381
  from.splice(hit.index, 1);
368
382
  const done = [...all.done, finished];
@@ -375,10 +389,10 @@ export async function doneTodo(id, { result, topic, pick, groupId } = {}) {
375
389
  }
376
390
 
377
391
  /** 冻结:→ 冻结(id 保留) */
378
- export async function freezeTodo(id, { topic, pick, groupId } = {}) {
392
+ export async function freezeTodo(ref, { topic, pick, groupId } = {}) {
379
393
  const all = await loadAll(groupId);
380
- const hit = locateUnique(all, id, { topic, pick });
381
- if (hit.bucket === 'freeze') throw conflict(`task id=${id} 已经处于冻结`);
394
+ const hit = locateUnique(all, ref, { topic, pick });
395
+ if (hit.bucket === 'freeze') throw conflict(`task「${hit.task.text}」已经处于冻结`);
382
396
 
383
397
  const frozen = { ...hit.task, frozenAt: dartNow() };
384
398
  const from = [...all[hit.bucket]];
@@ -390,12 +404,12 @@ export async function freezeTodo(id, { topic, pick, groupId } = {}) {
390
404
  }
391
405
 
392
406
  /** 解冻:冻结 → 待办。id 与现有待办冲突时换新 id(契约 1 的下游) */
393
- export async function unfreezeTodo(id, { topic, pick, groupId } = {}) {
407
+ export async function unfreezeTodo(ref, { topic, pick, groupId } = {}) {
394
408
  const all = await loadAll(groupId);
395
- const hit = locateUnique(all, id, { topic, pick });
396
- if (hit.bucket !== 'freeze') throw conflict(`task id=${id} 不在冻结区(当前在 ${hit.bucket})`);
409
+ const hit = locateUnique(all, ref, { topic, pick });
410
+ if (hit.bucket !== 'freeze') throw conflict(`task「${hit.task.text}」不在冻结区(当前在 ${hit.bucket})`);
397
411
 
398
- const clashes = all.open.some((t) => t.id === id);
412
+ const clashes = all.open.some((t) => t.id === hit.task.id);
399
413
  const revived = {
400
414
  ...hit.task,
401
415
  id: clashes ? nextTaskId(all.open, all.freeze) : hit.task.id,