dsh-plugin-mobile-link 0.4.1 → 0.6.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/README.md +2 -1
- package/cordis.patch.yml +23 -0
- package/lib/cli.js +6 -6
- package/lib/client.js +493 -0
- package/lib/hello.js +15 -2
- package/lib/history/probe.js +195 -0
- package/lib/history/records.js +97 -0
- package/lib/history/shared.js +25 -0
- package/lib/history/window.js +90 -0
- package/lib/history.js +355 -0
- package/lib/index.js +29 -6
- package/lib/link.js +88 -0
- package/lib/qr.js +34 -5
- package/lib/router.js +22 -0
- package/lib/routes.js +58 -55
- package/package.json +5 -1
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { HOST_CALL_TIMEOUT_MS, withTimeout, messageOf } from './shared.js'
|
|
2
|
+
import { bytesOf, summarizeEvents } from './records.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 只读测量(spec 007 T008/T047):`GET /mobile-link/history-probe` 用,锁定映射之后删。
|
|
6
|
+
*
|
|
7
|
+
* 它只读、不改任何状态;`getQuery` 是个取值函数而不是服务对象本身,因为宿主注入
|
|
8
|
+
* (`ctx.inject(['sessionQuery'])`)可能在服务建好之后才发生。
|
|
9
|
+
*/
|
|
10
|
+
export function createProbeRunner({ getQuery, logger }) {
|
|
11
|
+
return async function probe(args = {}) {
|
|
12
|
+
const sessionId = args.sessionId
|
|
13
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
14
|
+
return { ok: false, error: 'sessionId is required' }
|
|
15
|
+
}
|
|
16
|
+
if (getQuery() === undefined) {
|
|
17
|
+
return { ok: false, error: 'ctx.getQuery() is not mounted in this Host' }
|
|
18
|
+
}
|
|
19
|
+
const limit = Number.isFinite(Number(args.n)) && Number(args.n) > 0 ? Number(args.n) : 60
|
|
20
|
+
const out = { ok: true, sessionId, limit }
|
|
21
|
+
|
|
22
|
+
// 1. Whole log: how many events, and is log position == seq?
|
|
23
|
+
const t0 = Date.now()
|
|
24
|
+
try {
|
|
25
|
+
const records = await withTimeout(getQuery().listEvents(sessionId), HOST_CALL_TIMEOUT_MS, 'listEvents')
|
|
26
|
+
const mismatches = []
|
|
27
|
+
records.forEach((record, index) => {
|
|
28
|
+
if (record.seq !== index && mismatches.length < 5) mismatches.push({ index, seq: record.seq })
|
|
29
|
+
})
|
|
30
|
+
out.listEvents = {
|
|
31
|
+
...summarizeEvents(records),
|
|
32
|
+
ms: Date.now() - t0,
|
|
33
|
+
offsetEqualsSeq: mismatches.length === 0,
|
|
34
|
+
firstMismatches: mismatches,
|
|
35
|
+
}
|
|
36
|
+
} catch (error) {
|
|
37
|
+
out.listEvents = { ms: Date.now() - t0, error: messageOf(error) }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 2. Exact window read around a seq (`readEvent`): raw events, contiguous.
|
|
41
|
+
const targetSeq = Number.isFinite(Number(args.seq)) ? Number(args.seq) : out.listEvents?.lastSeq
|
|
42
|
+
if (Number.isInteger(targetSeq) && targetSeq >= 0) {
|
|
43
|
+
const t1 = Date.now()
|
|
44
|
+
try {
|
|
45
|
+
const window = await withTimeout(
|
|
46
|
+
getQuery().readEvent({ sessionId, seq: targetSeq, before: limit, after: 0 }),
|
|
47
|
+
HOST_CALL_TIMEOUT_MS,
|
|
48
|
+
'readEvent',
|
|
49
|
+
)
|
|
50
|
+
out.readEvent = {
|
|
51
|
+
requested: { seq: targetSeq, before: limit, after: 0 },
|
|
52
|
+
...summarizeEvents(window.events),
|
|
53
|
+
startSeq: window.startSeq ?? null,
|
|
54
|
+
endSeq: window.endSeq ?? null,
|
|
55
|
+
rawEventCount: window.events?.length ?? 0,
|
|
56
|
+
bytes: bytesOf(window.events),
|
|
57
|
+
ms: Date.now() - t1,
|
|
58
|
+
}
|
|
59
|
+
} catch (error) {
|
|
60
|
+
out.readEvent = { requested: { seq: targetSeq, before: limit }, ms: Date.now() - t1, error: messageOf(error) }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 3. Seq-range filter (`filterEvents`): message-aligned semantic documents.
|
|
65
|
+
if (Number.isInteger(targetSeq) && targetSeq >= 0) {
|
|
66
|
+
const from = Math.max(0, targetSeq - limit + 1)
|
|
67
|
+
const t2 = Date.now()
|
|
68
|
+
try {
|
|
69
|
+
const docs = await withTimeout(
|
|
70
|
+
getQuery().filterEvents(sessionId, [{ kind: 'seq', from, to: targetSeq }]),
|
|
71
|
+
HOST_CALL_TIMEOUT_MS,
|
|
72
|
+
'filterEvents(seq)',
|
|
73
|
+
)
|
|
74
|
+
out.filterEventsSeq = {
|
|
75
|
+
requested: { from, to: targetSeq },
|
|
76
|
+
...summarizeEvents(docs),
|
|
77
|
+
bytes: bytesOf(docs),
|
|
78
|
+
sample: docs.slice(0, 2).map((doc) => ({ seq: doc.seq, type: doc.type, textLen: doc.text?.length ?? 0 })),
|
|
79
|
+
ms: Date.now() - t2,
|
|
80
|
+
}
|
|
81
|
+
} catch (error) {
|
|
82
|
+
out.filterEventsSeq = { requested: { from, to: targetSeq }, ms: Date.now() - t2, error: messageOf(error) }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 4. Literal text scan (`filterEvents(text)`): the fallback for search when
|
|
87
|
+
// the full-text index is closed (base bundle ships `openAt: never`).
|
|
88
|
+
if (typeof args.text === 'string' && args.text.length > 0) {
|
|
89
|
+
const t3 = Date.now()
|
|
90
|
+
try {
|
|
91
|
+
const docs = await withTimeout(
|
|
92
|
+
getQuery().filterEvents(sessionId, [{ kind: 'text', text: args.text }]),
|
|
93
|
+
HOST_CALL_TIMEOUT_MS,
|
|
94
|
+
'filterEvents(text)',
|
|
95
|
+
)
|
|
96
|
+
out.filterEventsText = {
|
|
97
|
+
query: args.text,
|
|
98
|
+
...summarizeEvents(docs),
|
|
99
|
+
sample: docs.slice(0, 3).map((doc) => ({
|
|
100
|
+
seq: doc.seq,
|
|
101
|
+
type: doc.type,
|
|
102
|
+
textLen: doc.text?.length ?? 0,
|
|
103
|
+
head: (doc.text ?? '').slice(0, 60),
|
|
104
|
+
})),
|
|
105
|
+
ms: Date.now() - t3,
|
|
106
|
+
}
|
|
107
|
+
} catch (error) {
|
|
108
|
+
out.filterEventsText = { query: args.text, ms: Date.now() - t3, error: messageOf(error) }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 5. Indexed search (`searchEvents`): expected to fail while the SQLite
|
|
113
|
+
// index is closed, which is why step 4 must be able to carry search.
|
|
114
|
+
if (typeof args.text === 'string' && args.text.length > 0) {
|
|
115
|
+
const t4 = Date.now()
|
|
116
|
+
try {
|
|
117
|
+
const page = await withTimeout(
|
|
118
|
+
getQuery().searchEvents({ sessionId, query: args.text, limit: 20 }),
|
|
119
|
+
HOST_CALL_TIMEOUT_MS,
|
|
120
|
+
'searchEvents',
|
|
121
|
+
)
|
|
122
|
+
out.searchEvents = {
|
|
123
|
+
query: args.text,
|
|
124
|
+
count: page.items?.length ?? 0,
|
|
125
|
+
hasMore: page.hasMore ?? null,
|
|
126
|
+
first: page.items?.[0] ? { seq: page.items[0].seq, type: page.items[0].type } : null,
|
|
127
|
+
ms: Date.now() - t4,
|
|
128
|
+
}
|
|
129
|
+
} catch (error) {
|
|
130
|
+
out.searchEvents = { query: args.text, ms: Date.now() - t4, error: messageOf(error) }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 7. Chunked literal scan: `filterEvents(text)` returns every match with its
|
|
135
|
+
// full text, so the connector must bound each call by a seq range and
|
|
136
|
+
// stop at the requested hit count. This measures that shape.
|
|
137
|
+
if (typeof args.text === 'string' && args.text.length > 0 && Number.isInteger(out.listEvents?.lastSeq ?? null)) {
|
|
138
|
+
const chunk = Number.isFinite(Number(args.chunk)) && Number(args.chunk) > 0 ? Number(args.chunk) : 2000
|
|
139
|
+
const wanted = Number.isFinite(Number(args.hits)) && Number(args.hits) > 0 ? Number(args.hits) : 50
|
|
140
|
+
const lastSeq = out.listEvents.lastSeq
|
|
141
|
+
const t6 = Date.now()
|
|
142
|
+
const hits = []
|
|
143
|
+
let calls = 0
|
|
144
|
+
let to = lastSeq
|
|
145
|
+
try {
|
|
146
|
+
while (to >= 0 && hits.length < wanted && calls < 50) {
|
|
147
|
+
const from = Math.max(0, to - chunk + 1)
|
|
148
|
+
const docs = await withTimeout(
|
|
149
|
+
getQuery().filterEvents(sessionId, [
|
|
150
|
+
{ kind: 'seq', from, to },
|
|
151
|
+
{ kind: 'text', text: args.text },
|
|
152
|
+
]),
|
|
153
|
+
HOST_CALL_TIMEOUT_MS,
|
|
154
|
+
`filterEvents(chunk ${from}-${to})`,
|
|
155
|
+
)
|
|
156
|
+
calls++
|
|
157
|
+
for (const doc of docs) {
|
|
158
|
+
if (hits.length >= wanted) break
|
|
159
|
+
hits.push({ seq: doc.seq, type: doc.type, textLen: doc.text?.length ?? 0 })
|
|
160
|
+
}
|
|
161
|
+
to = from - 1
|
|
162
|
+
}
|
|
163
|
+
out.chunkedText = {
|
|
164
|
+
query: args.text,
|
|
165
|
+
chunk,
|
|
166
|
+
calls,
|
|
167
|
+
hits: hits.length,
|
|
168
|
+
firstSeq: hits[0]?.seq ?? null,
|
|
169
|
+
lastSeq: hits[hits.length - 1]?.seq ?? null,
|
|
170
|
+
scannedFrom: to + 1,
|
|
171
|
+
ms: Date.now() - t6,
|
|
172
|
+
}
|
|
173
|
+
} catch (error) {
|
|
174
|
+
out.chunkedText = { query: args.text, calls, hits: hits.length, ms: Date.now() - t6, error: messageOf(error) }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 6. Cold read: no Agent activation. `readSession` returns the log snapshot.
|
|
179
|
+
const t5 = Date.now()
|
|
180
|
+
try {
|
|
181
|
+
const snapshot = await withTimeout(getQuery().readSession(sessionId), HOST_CALL_TIMEOUT_MS, 'readSession')
|
|
182
|
+
out.readSession = {
|
|
183
|
+
events: snapshot.events?.length ?? null,
|
|
184
|
+
startSeq: snapshot.startSeq ?? null,
|
|
185
|
+
endSeq: snapshot.endSeq ?? null,
|
|
186
|
+
headerKeys: snapshot.session ? Object.keys(snapshot.session).slice(0, 8) : [],
|
|
187
|
+
ms: Date.now() - t5,
|
|
188
|
+
}
|
|
189
|
+
} catch (error) {
|
|
190
|
+
out.readSession = { ms: Date.now() - t5, error: messageOf(error) }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return out
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `_link/sessionWindow` / `_link/sessionFind` 共用的事件→记录件。
|
|
3
|
+
*
|
|
4
|
+
* 抽出来的理由与 `shared.js` 同一条(spec 007 T065):窗口扫描、检索、探针三块
|
|
5
|
+
* 都要按角色归类事件、都要按字节预算裁剪,放在 `history.js` 里会把那个文件撑到
|
|
6
|
+
* 读不动,也确实漏过一次导入(`probe.js` 里 `bytesOf`/`summarizeEvents` 一直是
|
|
7
|
+
* 未定义的名字)。
|
|
8
|
+
*
|
|
9
|
+
* 这里只有纯函数:没有 IO、没有服务状态。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/// Host hard cap: `readEvent` rejects `before > 50` (`before must be an integer
|
|
13
|
+
/// between 0 and 50`). One chunk therefore yields at most 51 raw events.
|
|
14
|
+
export const WINDOW_CHUNK = 50
|
|
15
|
+
|
|
16
|
+
/// Defaults for one page. The phone may override both; the byte budget wins when
|
|
17
|
+
/// they disagree (measured: 51 raw events can already be 253KB on a long session).
|
|
18
|
+
export const DEFAULT_LIMIT = 60
|
|
19
|
+
export const DEFAULT_MAX_BYTES = 256 * 1024
|
|
20
|
+
|
|
21
|
+
/// Ceiling on how much one call reads, so a page request can never pin the
|
|
22
|
+
/// connector's serial inbox (measured: ~30ms per chunk on a 2.3k session).
|
|
23
|
+
export const MAX_CHUNKS_PER_CALL = 12
|
|
24
|
+
|
|
25
|
+
export function bytesOf(value) {
|
|
26
|
+
try {
|
|
27
|
+
return Buffer.byteLength(JSON.stringify(value), 'utf8')
|
|
28
|
+
} catch {
|
|
29
|
+
return -1
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function summarizeEvents(events) {
|
|
34
|
+
if (!Array.isArray(events) || events.length === 0) {
|
|
35
|
+
return { count: 0, firstSeq: null, lastSeq: null, types: [] }
|
|
36
|
+
}
|
|
37
|
+
const types = [...new Set(events.map((event) => event?.type).filter(Boolean))].slice(0, 8)
|
|
38
|
+
return {
|
|
39
|
+
count: events.length,
|
|
40
|
+
firstSeq: events[0]?.seq ?? null,
|
|
41
|
+
lastSeq: events[events.length - 1]?.seq ?? null,
|
|
42
|
+
types,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function toRecord(event) {
|
|
47
|
+
return { type: 'event', event }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function bytesOfRecords(records) {
|
|
51
|
+
return bytesOf(records)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// Drop the **oldest** end until the page fits. The newest edge must stay exact
|
|
55
|
+
/// (it is the seq the phone's window abuts), so trimming only ever happens here.
|
|
56
|
+
export function trimToBytes(records, maxBytes) {
|
|
57
|
+
let truncated = false
|
|
58
|
+
while (records.length > 1 && bytesOfRecords(records) > maxBytes) {
|
|
59
|
+
records.shift()
|
|
60
|
+
truncated = true
|
|
61
|
+
}
|
|
62
|
+
return truncated
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Which of the three transcript roles one event belongs to (the phone's own
|
|
66
|
+
/// `TranscriptSearchHit.Role`).
|
|
67
|
+
///
|
|
68
|
+
/// `user/message` only counts as **me** when the event really came from the
|
|
69
|
+
/// user: measured on a real session, ~24% of `user/message` events are
|
|
70
|
+
/// plugin/system injections (`data.source.kind` other than `"user"`), and
|
|
71
|
+
/// agent-relayed ones carry a `data.source.rpcId` starting with `agent-`.
|
|
72
|
+
/// Events with no `data.source` at all predate the marker and stay `me`
|
|
73
|
+
/// (old-data compatibility). Non-`me` user messages return `undefined` —
|
|
74
|
+
/// they remain visible to full-text search, they just never enter the
|
|
75
|
+
/// "my questions" set (spec 009 T031).
|
|
76
|
+
export function roleOf(event) {
|
|
77
|
+
const type = event?.type
|
|
78
|
+
if (type === 'user/message') {
|
|
79
|
+
const source = event?.data?.source
|
|
80
|
+
if (source !== undefined && source !== null && typeof source === 'object') {
|
|
81
|
+
if (source.kind !== undefined && source.kind !== 'user') return undefined
|
|
82
|
+
if (typeof source.rpcId === 'string' && source.rpcId.startsWith('agent-')) return undefined
|
|
83
|
+
}
|
|
84
|
+
return 'me'
|
|
85
|
+
}
|
|
86
|
+
if (type === 'assistant/message') return 'agent'
|
|
87
|
+
if (type === 'tool/call' || type === 'tool/result') return 'tool'
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// What the search hit is *displayed* as. Injected/relayed user messages are
|
|
92
|
+
/// not "me" for the curated set, but they are still user speech in full-text
|
|
93
|
+
/// search (spec 009: excluded from "我的提问", not from the corpus), so an
|
|
94
|
+
/// unfiltered search shows them under the user bucket.
|
|
95
|
+
export function displayRoleOf(event) {
|
|
96
|
+
return roleOf(event) ?? (event?.type === 'user/message' ? 'me' : undefined)
|
|
97
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `_link/sessionWindow` / `_link/sessionFind` 共用的小工具:调用上限、超时与错误文案。
|
|
3
|
+
*
|
|
4
|
+
* 抽出来的理由:这两个助手原先在 `history.js` 里,而窗口、检索、探针三块都要用;
|
|
5
|
+
* 放在这里可以让三块各自成文件(spec 007 T065)。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/// Host-side call ceiling. The connector's inbox is serial, so one slow query
|
|
9
|
+
/// would otherwise hold up every other frame on the tunnel.
|
|
10
|
+
export const HOST_CALL_TIMEOUT_MS = 8000
|
|
11
|
+
|
|
12
|
+
/// 包一层超时:连接器的入站链是**串行**的,一次不返回的宿主调用会卡住整条链。
|
|
13
|
+
export function withTimeout(promise, ms, label) {
|
|
14
|
+
let timer
|
|
15
|
+
const timeout = new Promise((_, reject) => {
|
|
16
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)
|
|
17
|
+
})
|
|
18
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function messageOf(error) {
|
|
22
|
+
if (error === undefined || error === null) return 'unknown error'
|
|
23
|
+
if (typeof error === 'string') return error
|
|
24
|
+
return error.message ?? String(error)
|
|
25
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `_link/sessionWindow` 的「往下扫」与「把扫描结果裁成一页」两块(spec 007 T065
|
|
3
|
+
* 的同一条理由:让 `history.js` 留得下校验、在飞锁与检索)。
|
|
4
|
+
*
|
|
5
|
+
* 唯一的 IO 是调用方给的 `readChunk`,所以这里没有服务状态、可以直接单测。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { MAX_CHUNKS_PER_CALL, bytesOfRecords, roleOf, toRecord, trimToBytes } from './records.js'
|
|
9
|
+
|
|
10
|
+
/// One backward scan from `before - 1` (spec 009 T039).
|
|
11
|
+
///
|
|
12
|
+
/// Returns the matching events **and** `scannedFloor`: the oldest seq this call
|
|
13
|
+
/// actually examined (inclusive). It is about *where the scan got to*, not about
|
|
14
|
+
/// which events matched — a page whose whole stretch is filtered out still moves
|
|
15
|
+
/// it, which is what lets the phone's role-filtered backfill advance. (The old
|
|
16
|
+
/// cursor rule — the oldest returned record — stands still on an empty page.)
|
|
17
|
+
///
|
|
18
|
+
/// `exhausted` means the scan ran into the start of the log (or an empty chunk):
|
|
19
|
+
/// a filtered page needs it to answer "is there more?".
|
|
20
|
+
export async function scanOlder({ readChunk, sessionId, before, through, limit, maxBytes, roles }) {
|
|
21
|
+
let cursor = Math.min(before - 1, through ?? Number.MAX_SAFE_INTEGER)
|
|
22
|
+
const events = []
|
|
23
|
+
let truncatedBy = null
|
|
24
|
+
let clamped = false
|
|
25
|
+
let exhausted = false
|
|
26
|
+
let scannedFloor = null
|
|
27
|
+
for (let chunk = 0; chunk < MAX_CHUNKS_PER_CALL && cursor >= 0; chunk += 1) {
|
|
28
|
+
const { window: result, clamped: thisClamped } = await readChunk(sessionId, cursor)
|
|
29
|
+
clamped = clamped || thisClamped
|
|
30
|
+
const batch = Array.isArray(result.events) ? result.events : []
|
|
31
|
+
if (batch.length === 0) { exhausted = true; scannedFloor = 0; break }
|
|
32
|
+
const lowest = result.startSeq ?? batch[0].seq
|
|
33
|
+
scannedFloor = scannedFloor === null ? lowest : Math.min(scannedFloor, lowest)
|
|
34
|
+
for (let index = batch.length - 1; index >= 0; index -= 1) {
|
|
35
|
+
const event = batch[index]
|
|
36
|
+
if (roles !== undefined && !roles.has(roleOf(event))) continue
|
|
37
|
+
events.unshift(event)
|
|
38
|
+
}
|
|
39
|
+
if (events.length >= limit || bytesOfRecords(events.map(toRecord)) >= maxBytes) {
|
|
40
|
+
truncatedBy = events.length >= limit ? 'count' : 'bytes'
|
|
41
|
+
break
|
|
42
|
+
}
|
|
43
|
+
const next = lowest - 1
|
|
44
|
+
if (next >= cursor) { exhausted = true; break }
|
|
45
|
+
cursor = next
|
|
46
|
+
if (cursor < 0) { exhausted = true; break }
|
|
47
|
+
}
|
|
48
|
+
return { events, truncatedBy, clamped, exhausted, scannedFloor }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// Shape one scan into the page the phone gets.
|
|
52
|
+
///
|
|
53
|
+
/// Trimming only ever drops the **oldest** end: the newest edge is the seq the
|
|
54
|
+
/// phone's window abuts, so it must stay exact (one chunk is 51 events, so the
|
|
55
|
+
/// page would otherwise overshoot `limit` by up to a chunk).
|
|
56
|
+
///
|
|
57
|
+
/// `scanBoundary` is what the caller passes as the next `before`. Normally that
|
|
58
|
+
/// is `scannedFloor` — the stretch below it was never looked at. When trimming
|
|
59
|
+
/// dropped records, it falls back to the oldest **returned** record instead:
|
|
60
|
+
/// dropped records were not handed over, so the next page has to scan them
|
|
61
|
+
/// again (re-scanning is free, losing one question is not). Without `roles` the
|
|
62
|
+
/// field mirrors `startSeq`: old clients read the old fields and see no change.
|
|
63
|
+
export function pageFrom(scan, { limit, maxBytes, roles }) {
|
|
64
|
+
const records = scan.events.map(toRecord)
|
|
65
|
+
let truncatedBy = scan.truncatedBy
|
|
66
|
+
let droppedOldest = false
|
|
67
|
+
if (records.length > limit) {
|
|
68
|
+
records.splice(0, records.length - limit)
|
|
69
|
+
truncatedBy = 'count'
|
|
70
|
+
droppedOldest = true
|
|
71
|
+
}
|
|
72
|
+
if (trimToBytes(records, maxBytes)) { truncatedBy = 'bytes'; droppedOldest = true }
|
|
73
|
+
if (records.length === 0) {
|
|
74
|
+
return {
|
|
75
|
+
records: [], startSeq: null, endSeq: null,
|
|
76
|
+
hasMore: roles !== undefined && !scan.exhausted,
|
|
77
|
+
truncatedBy: null, clamped: scan.clamped,
|
|
78
|
+
scanBoundary: roles === undefined ? null : scan.scannedFloor,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const startSeq = records[0].event.seq
|
|
82
|
+
const endSeq = records[records.length - 1].event.seq
|
|
83
|
+
// Filtered pages: "more" means older seqs remain unscanned **and** the page
|
|
84
|
+
// did not already reach the log's first event.
|
|
85
|
+
const hasMore = roles === undefined ? startSeq > 0 : (startSeq > 0 && !scan.exhausted)
|
|
86
|
+
return {
|
|
87
|
+
records, startSeq, endSeq, hasMore, truncatedBy, clamped: scan.clamped,
|
|
88
|
+
scanBoundary: roles === undefined ? startSeq : (droppedOldest ? startSeq : scan.scannedFloor),
|
|
89
|
+
}
|
|
90
|
+
}
|