dsh-plugin-mobile-link 0.6.5 → 0.7.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.
@@ -41,39 +41,90 @@ export function createSnapshotCache({
41
41
  } = {}) {
42
42
  const entries = new Map()
43
43
 
44
+ /// 同一会话的并发读只打一次宿主(预热与首屏可能同时到)。
45
+ const loading = new Map()
46
+
47
+ function fresh(sessionId) {
48
+ const cached = entries.get(sessionId)
49
+ if (cached === undefined || now() - cached.at >= ttlMs) return undefined
50
+ // LRU:命中后挪到队尾。
51
+ entries.delete(sessionId)
52
+ entries.set(sessionId, cached)
53
+ return cached
54
+ }
55
+
56
+ async function load(sessionId) {
57
+ if (typeof readSession !== 'function') return undefined
58
+ let snapshot
59
+ try {
60
+ snapshot = await readSession(sessionId)
61
+ } catch {
62
+ return undefined
63
+ }
64
+ const events = snapshot?.events
65
+ if (!Array.isArray(events) || events.length > maxEvents) return undefined
66
+ const entry = {
67
+ events,
68
+ newestSeq: events.length > 0 ? events[events.length - 1].seq : -1,
69
+ at: now(),
70
+ }
71
+ entries.set(sessionId, entry)
72
+ while (entries.size > maxEntries) entries.delete(entries.keys().next().value)
73
+ return entry
74
+ }
75
+
44
76
  return {
45
77
  /** 命中且新鲜就返回;否则读一次;读不到/超限/形状不对一律 undefined。 */
46
78
  async get(sessionId) {
47
- const cached = entries.get(sessionId)
48
- if (cached !== undefined && now() - cached.at < ttlMs) {
49
- // LRU:命中后挪到队尾。
50
- entries.delete(sessionId)
51
- entries.set(sessionId, cached)
52
- return cached
53
- }
54
- if (typeof readSession !== 'function') return undefined
55
- let snapshot
56
- try {
57
- snapshot = await readSession(sessionId)
58
- } catch {
59
- return undefined
60
- }
61
- const events = snapshot?.events
62
- if (!Array.isArray(events) || events.length > maxEvents) return undefined
63
- const entry = {
64
- events,
65
- newestSeq: events.length > 0 ? events[events.length - 1].seq : -1,
66
- at: now(),
67
- }
68
- entries.set(sessionId, entry)
69
- while (entries.size > maxEntries) entries.delete(entries.keys().next().value)
70
- return entry
79
+ const cached = fresh(sessionId)
80
+ if (cached !== undefined) return cached
81
+ const pending = loading.get(sessionId)
82
+ if (pending !== undefined) return pending
83
+ const task = load(sessionId).finally(() => loading.delete(sessionId))
84
+ loading.set(sessionId, task)
85
+ return task
86
+ },
87
+ /** 只看缓存,不读宿主(浏览翻页第一次请求用它,避免首屏多付一次整份读)。 */
88
+ peek(sessionId) { return fresh(sessionId) },
89
+ /** 后台预热:不阻塞当前请求,下一次翻页就能命中。 */
90
+ warm(sessionId) {
91
+ if (fresh(sessionId) !== undefined) return
92
+ if (loading.has(sessionId)) return
93
+ void this.get(sessionId)
71
94
  },
72
95
  invalidate(sessionId) { entries.delete(sessionId) },
73
96
  get size() { return entries.size },
74
97
  }
75
98
  }
76
99
 
100
+ /**
101
+ * 快照上的「再往旧读一批」读取器(概要 v2 用):`(cursor) => {events, floorSeq, exhausted, clamped}`。
102
+ *
103
+ * 与 `scanSnapshot` 的区别只在职责:`scanSnapshot` 是"过滤 + 交给 `pageFrom` 裁一页",
104
+ * 这里只回答"seq ≤ cursor 的原始事件有哪些",因为概要 v2 的分页单位是**整轮**,
105
+ * 裁剪由 `summary-window.js` 按轮做。事件已经在内存里,所以这一层是同步的。
106
+ */
107
+ export function snapshotOlderReader(snapshot) {
108
+ const events = Array.isArray(snapshot?.events) ? snapshot.events : []
109
+ const newest = Number.isInteger(snapshot?.newestSeq) ? snapshot.newestSeq : -1
110
+ return (cursor) => {
111
+ const batch = []
112
+ for (const event of events) {
113
+ if (!Number.isInteger(event?.seq)) continue
114
+ if (event.seq > cursor) break
115
+ batch.push(event)
116
+ }
117
+ const floorSeq = batch.length > 0 ? batch[0].seq : 0
118
+ return {
119
+ events: batch,
120
+ floorSeq,
121
+ // 日志被裁剪过时第一条的 seq 可能不是 0;下一次读到空批就自然收敛(见调用方)。
122
+ exhausted: floorSeq === 0,
123
+ clamped: cursor > newest,
124
+ }
125
+ }
126
+ }
127
+
77
128
  /**
78
129
  * 从快照扫一页。**纯函数**,返回形状与 `scanOlder` / `filteredScan` 完全一致,
79
130
  * 于是裁剪与分页仍然由 `pageFrom` 一处决定。
@@ -0,0 +1,168 @@
1
+ /**
2
+ * 概要 v2 的**整轮切页**(能力 `history-fold-summary-v2`)。
3
+ *
4
+ * 规格(`maintainers/设计/20261025-过程折叠-对齐桌面.md` §3.4 第 5 条):
5
+ * 窗口**从轮次边界开始、到轮次边界结束**;一页装不下整轮就少给轮次,不能给半轮。
6
+ * 这样手机侧不再出现"记录不完整"与"连续孤立摘要"两种 UI(设计 §5 G9)。
7
+ *
8
+ * 唯一的 IO 是调用方给的 `readOlder(cursor)`,返回 `seq ≤ cursor` 的原始事件
9
+ * (升序)+ `floorSeq` / `exhausted` / `clamped`。两个实现(会话快照 / 分块回读)
10
+ * 形状相同,所以这里可以纯逻辑地单测。
11
+ *
12
+ * 三条不变量,任何改动都不许破坏:
13
+ * 1. **绝不下发半轮**:一轮的事件没读全(到了调用上限或时间预算)就整轮丢掉;
14
+ * 连最新一轮都读不全时回可重试错误,而不是给一段残缺的过程。
15
+ * 2. **至少给一轮**:`limit` 比一轮投影后的记录数还小也照给这一轮——否则翻页会卡在
16
+ * 同一处永远前进不了(客户端按 `startSeq` 当游标)。
17
+ * 3. **游标落在轮次边界**:`startSeq` 永远是某个 `turn/start`(或日志真正的起点),
18
+ * 下一次请求 `before = startSeq` 正好落在上一轮的 `turn/end` 之后。
19
+ */
20
+
21
+ import { projectTurnUnit } from './fold-summary.js'
22
+
23
+ /// 一次调用最多回读多少批原始事件。分块路径一批 51 条(宿主 `readEvent` 的上限),
24
+ /// 64 批 ≈ 3264 条 ≈ 1.5–2 s;快照路径一批就是整份日志,用不到这个数。
25
+ export const SUMMARY_MAX_BATCHES = 64
26
+ /// 一次调用最多花多久在"往回读"上(毫秒)。到点即按"没读全"处理,绝不下发半轮。
27
+ export const SUMMARY_DEADLINE_MS = 6000
28
+ /// 一页最多塞多少轮。防"预算设得极大 + 会话极长"时把一页做成整份日志。
29
+ export const SUMMARY_MAX_UNITS = 200
30
+
31
+ const EMPTY_PAGE = {
32
+ records: [], summaries: [], startSeq: null, endSeq: null,
33
+ hasMore: false, truncatedBy: null, clamped: false,
34
+ }
35
+
36
+ /**
37
+ * 从 `upper` 往回按整轮取一页。
38
+ *
39
+ * @param {object} input
40
+ * @param {(cursor: number) => ({events?: Array<object>, floorSeq?: number, exhausted?: boolean, clamped?: boolean})
41
+ * | Promise<object>} input.readOlder 往回读一批(`seq ≤ cursor`)
42
+ * @param {number} input.upper 本页最新一条的 seq(含)
43
+ * @param {number} input.limit 本页最多几**轮**(本模式的页单位是轮;普通窗口里它是记录条数)
44
+ * @param {number} input.maxBytes 字节预算(按**投影后**的下发记录算)
45
+ * @param {boolean} [input.clamped] 调用方已经把上界夹到日志最新(越界请求)
46
+ * @param {number} [input.maxBatches] 回读批数上限
47
+ * @param {number} [input.deadlineMs] 回读时间上限
48
+ * @param {() => number} [input.now] 时钟(测试用)
49
+ * @returns {Promise<object>} 页;`{error:{code,message}}` 表示这一页取不出来
50
+ */
51
+ export async function readSummaryPage({
52
+ readOlder,
53
+ upper,
54
+ limit,
55
+ maxBytes,
56
+ clamped: upperClamped = false,
57
+ maxBatches = SUMMARY_MAX_BATCHES,
58
+ deadlineMs = SUMMARY_DEADLINE_MS,
59
+ now = Date.now,
60
+ }) {
61
+ if (!Number.isInteger(upper) || upper < 0) return { ...EMPTY_PAGE }
62
+ const startedAt = now()
63
+ const stack = []
64
+ let nextRead = upper
65
+ let exhausted = false
66
+ let capped = false
67
+ let clamped = upperClamped === true
68
+ let batches = 0
69
+
70
+ const budgetSpent = () => batches >= maxBatches || now() - startedAt >= deadlineMs
71
+
72
+ async function fill() {
73
+ if (stack.length > 0 || exhausted) return
74
+ if (budgetSpent()) { capped = true; return }
75
+ const batch = await readOlder(nextRead)
76
+ batches += 1
77
+ if (batch?.clamped === true) clamped = true
78
+ const events = Array.isArray(batch?.events) ? batch.events : []
79
+ for (const event of events) {
80
+ if (!Number.isInteger(event?.seq) || event.seq > nextRead) continue
81
+ stack.push(event)
82
+ }
83
+ const floorSeq = Number.isInteger(batch?.floorSeq)
84
+ ? batch.floorSeq
85
+ : (events[0]?.seq ?? 0)
86
+ if (batch?.exhausted === true || floorSeq <= 0 || events.length === 0) exhausted = true
87
+ nextRead = Math.min(nextRead, floorSeq) - 1
88
+ }
89
+
90
+ /// 读一轮:往回读到 `turn/start`(含)为止。`complete` 为假表示没读全(上限/没数据),
91
+ /// 调用方必须整轮丢掉。
92
+ async function readUnit() {
93
+ const events = []
94
+ while (true) {
95
+ await fill()
96
+ if (stack.length === 0) return { events: events.reverse(), complete: exhausted }
97
+ const event = stack.pop()
98
+ events.push(event)
99
+ if (event.type === 'turn/start') return { events: events.reverse(), complete: true }
100
+ }
101
+ }
102
+
103
+ const kept = []
104
+ let byteCount = 0
105
+ let unitsSeen = 0
106
+ let droppedHalf = false
107
+ let stop = null
108
+
109
+ for (let index = 0; index < SUMMARY_MAX_UNITS; index += 1) {
110
+ const { events, complete } = await readUnit()
111
+ if (events.length === 0) { stop ??= 'done'; break }
112
+ unitsSeen += 1
113
+ if (!complete) {
114
+ // 半轮:整轮丢掉(最新一轮也不破例,宁可报错让客户端重试)。
115
+ droppedHalf = true
116
+ stop = 'incomplete'
117
+ break
118
+ }
119
+ const projected = projectTurnUnit(events)
120
+ if (projected.records.length > 0) {
121
+ // 字节预算只约束"较旧的那些轮":最新的这一轮无论如何都要给全(宁可超一点,
122
+ // 也不给半轮、也不让客户端永远看不到当前轮)。
123
+ if (kept.length > 0 && byteCount + projected.bytes > maxBytes) { stop = 'bytes'; break }
124
+ // 页边界取**整轮原始事件**的首尾,不是首尾下发记录:一轮起点前的
125
+ // `request/header` 之类虽然在 v2 里不下发,但它们确实属于这一页的 seq 段,
126
+ // 游标必须越过它们,否则下一次请求会把同一段重扫一遍。
127
+ kept.push({
128
+ ...projected,
129
+ unitStartSeq: events[0].seq,
130
+ unitEndSeq: events[events.length - 1].seq,
131
+ })
132
+ byteCount += projected.bytes
133
+ }
134
+ if (kept.length >= limit) { stop = 'count'; break }
135
+ if (byteCount >= maxBytes) { stop = 'bytes'; break }
136
+ if (exhausted && stack.length === 0) { stop = 'done'; break }
137
+ }
138
+ if (stop === null) stop = 'units'
139
+
140
+ if (kept.length === 0) {
141
+ if (droppedHalf && unitsSeen > 0) {
142
+ return {
143
+ error: {
144
+ code: 'link/too-large',
145
+ message: 'one turn cannot be read within a single page; retry',
146
+ },
147
+ }
148
+ }
149
+ return { ...EMPTY_PAGE, clamped }
150
+ }
151
+
152
+ const ordered = [...kept].reverse()
153
+ const records = ordered.flatMap((unit) => unit.records)
154
+ const summaries = ordered.flatMap((unit) => unit.summaries)
155
+ return {
156
+ records,
157
+ summaries,
158
+ startSeq: ordered[0].unitStartSeq,
159
+ endSeq: ordered[ordered.length - 1].unitEndSeq,
160
+ // "还有更早的"只由"往回读到底没有"回答:预算停下、或者被回读上限截住,
161
+ // 都还有更早的轮次。游标永远是 `startSeq`(一轮的起点),不靠猜。
162
+ hasMore: !(exhausted && stack.length === 0),
163
+ truncatedBy: stop === 'count' ? 'count' : stop === 'bytes' ? 'bytes' : null,
164
+ clamped,
165
+ batches,
166
+ units: ordered.length,
167
+ }
168
+ }