@skyhook-io/radar-app 1.8.13 → 1.9.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/package.json +1 -1
- package/src/App.tsx +7 -4
- package/src/api/timelineSource.test.ts +464 -21
- package/src/api/timelineSource.ts +521 -184
- package/src/components/applications/ApplicationsView.tsx +2 -1
- package/src/components/home/mcpToolCatalog.ts +1 -1
- package/src/components/timeline/RetainedTimelineScrubber.tsx +21 -13
- package/src/components/timeline/TimelineList.tsx +72 -49
- package/src/components/timeline/TimelineView.tsx +66 -16
- package/src/utils/auditBadges.ts +1 -1
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -967,6 +967,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
967
967
|
if (tl.timer === null) {
|
|
968
968
|
tl.timer = window.setTimeout(() => {
|
|
969
969
|
queryClient.invalidateQueries({ queryKey: ['changes'] })
|
|
970
|
+
// The ring-and-delta timeline path: an invalidation costs one ~KB
|
|
971
|
+
// cursor delta, so SSE keeps the timeline fresh within seconds and
|
|
972
|
+
// the hook's 10s poll remains the no-SSE fallback.
|
|
973
|
+
queryClient.invalidateQueries({ queryKey: ['timeline-ring'] })
|
|
970
974
|
timelineInvalidationRef.current = { timer: null }
|
|
971
975
|
}, 5000)
|
|
972
976
|
}
|
|
@@ -1512,10 +1516,9 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1512
1516
|
}, [displayedTopology, visibleKinds, namespaces, topologyMode])
|
|
1513
1517
|
|
|
1514
1518
|
// Cluster Audit findings, joined onto topology nodes by the audit key the
|
|
1515
|
-
// backend stamps on each node (data.auditKey).
|
|
1516
|
-
//
|
|
1517
|
-
//
|
|
1518
|
-
// when there are findings to attach — no overhead on clusters with none.
|
|
1519
|
+
// backend stamps on each node (data.auditKey). Only badge-worthy findings
|
|
1520
|
+
// reach the graph; the raw auditDanger/auditWarning property names remain at
|
|
1521
|
+
// this compatibility boundary while the node presents them as High/Medium.
|
|
1519
1522
|
const audit = useAudit(namespaces)
|
|
1520
1523
|
const auditSeverityMap = useMemo(
|
|
1521
1524
|
() => buildAuditSeverityMap(audit.data?.findings, audit.data?.checks),
|
|
@@ -1,28 +1,26 @@
|
|
|
1
1
|
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
|
2
2
|
|
|
3
|
-
// The retained NDJSON parser goes through apiFetch; mock the
|
|
4
|
-
// test can hand
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
this.status = status
|
|
14
|
-
}
|
|
15
|
-
},
|
|
16
|
-
}))
|
|
17
|
-
|
|
18
|
-
import { apiFetch } from './client'
|
|
3
|
+
// The retained NDJSON parser goes through apiFetch; mock ONLY the network in
|
|
4
|
+
// the client module so a test can hand the stream readers an arbitrary body —
|
|
5
|
+
// ApiError and mergeDeltaEvents stay real (the ring fetch depends on the real
|
|
6
|
+
// merge semantics).
|
|
7
|
+
vi.mock('./client', async (importOriginal) => {
|
|
8
|
+
const actual = await importOriginal<typeof import('./client')>()
|
|
9
|
+
return { ...actual, apiFetch: vi.fn(), useChanges: vi.fn() }
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
import { apiFetch, ApiError } from './client'
|
|
19
13
|
import {
|
|
20
14
|
fetchRetainedWindow,
|
|
15
|
+
rangeSpanMs,
|
|
16
|
+
RETAINED_RING_LIMIT,
|
|
17
|
+
fetchRetainedDelta,
|
|
18
|
+
runRetainedRingFetch,
|
|
21
19
|
applyClientFilters,
|
|
22
20
|
localOverviewFromEvents,
|
|
23
|
-
|
|
21
|
+
needsWindowFetch,
|
|
22
|
+
type RetainedRing,
|
|
24
23
|
} from './timelineSource'
|
|
25
|
-
import { BASE_QUANTIZE_STEP_MS } from '@skyhook-io/k8s-ui'
|
|
26
24
|
import type { TimelineEvent } from '../types'
|
|
27
25
|
|
|
28
26
|
const mockApiFetch = vi.mocked(apiFetch)
|
|
@@ -152,6 +150,16 @@ describe('applyClientFilters', () => {
|
|
|
152
150
|
expect(out.map((e) => e.id)).toEqual(['del1', 'p1'])
|
|
153
151
|
})
|
|
154
152
|
|
|
153
|
+
// Retained mode passes no limit — the hub owns the window bound (31d + a 50k
|
|
154
|
+
// row hard stop that surfaces as a stream error). The client must never
|
|
155
|
+
// silently drop older events, so an unset limit returns the full window.
|
|
156
|
+
it('returns every event when no limit is set', () => {
|
|
157
|
+
const many: TimelineEvent[] = Array.from({ length: 12000 }, (_, i) =>
|
|
158
|
+
ev({ id: `e${i}`, kind: 'Pod', namespace: 'ns-a', timestamp: new Date(T0 + i).toISOString() }),
|
|
159
|
+
)
|
|
160
|
+
expect(applyClientFilters(many, {})).toHaveLength(12000)
|
|
161
|
+
})
|
|
162
|
+
|
|
155
163
|
// Mirrors Go's TimelineEvent.IsManaged: owned, or ReplicaSet/Pod/Event.
|
|
156
164
|
// Enforced client-side so retained mode (whose endpoint has no
|
|
157
165
|
// include_managed param) behaves exactly like local mode.
|
|
@@ -210,8 +218,443 @@ describe('localOverviewFromEvents', () => {
|
|
|
210
218
|
})
|
|
211
219
|
})
|
|
212
220
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
221
|
+
// The hook arms the frozen-window fetch on this predicate alone, so these pin
|
|
222
|
+
// all directions: ring-covered explicit windows stay zero-fetch, the LIVE
|
|
223
|
+
// sliding selection (an explicit window ending at the clock) stays zero-fetch
|
|
224
|
+
// even under hub-ahead clock skew and tick lag, and a frozen selection whose
|
|
225
|
+
// to-edge sits below the newest loaded ring row (past the tick slack) with a
|
|
226
|
+
// from-edge below the oldest escapes to the server — however close its
|
|
227
|
+
// to-edge is to the clock.
|
|
228
|
+
describe('needsWindowFetch (frozen-window coverage predicate)', () => {
|
|
229
|
+
const HOUR = 60 * 60 * 1000
|
|
230
|
+
const NOW = T0 + 24 * HOUR
|
|
231
|
+
const newestMs = NOW - HOUR
|
|
232
|
+
const oldestMs = NOW - 2 * HOUR
|
|
233
|
+
const ringEvents: TimelineEvent[] = [
|
|
234
|
+
ev({ id: 'newer', timestamp: new Date(newestMs).toISOString() }),
|
|
235
|
+
ev({ id: 'oldest', timestamp: new Date(oldestMs).toISOString() }),
|
|
236
|
+
]
|
|
237
|
+
// A frozen selection wholly below the ring's oldest loaded row — THE bug
|
|
238
|
+
// case: the ring slice for it is empty while the server holds rows.
|
|
239
|
+
const deepFromMs = NOW - 10 * HOUR
|
|
240
|
+
const deepToMs = NOW - 8 * HOUR
|
|
241
|
+
|
|
242
|
+
it('an untruncated ring answers every window — no fetch', () => {
|
|
243
|
+
// Even a from-edge far below the oldest loaded row: untruncated means the
|
|
244
|
+
// server sent everything it holds in the ring's depth.
|
|
245
|
+
expect(needsWindowFetch({ events: ringEvents, truncated: false }, deepFromMs, deepToMs, NOW)).toBe(false)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('a truncated ring still answers windows at or after its oldest loaded row', () => {
|
|
249
|
+
expect(needsWindowFetch({ events: ringEvents, truncated: true }, oldestMs, newestMs, NOW)).toBe(false)
|
|
250
|
+
expect(needsWindowFetch({ events: ringEvents, truncated: true }, oldestMs + 1, newestMs, NOW)).toBe(false)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('a from-edge below the oldest loaded row needs the server window when the to-edge is below the newest', () => {
|
|
254
|
+
expect(needsWindowFetch({ events: ringEvents, truncated: true }, deepFromMs, deepToMs, NOW)).toBe(true)
|
|
255
|
+
expect(needsWindowFetch({ events: ringEvents, truncated: true }, oldestMs - 1, newestMs - 5 * 60_000, NOW)).toBe(true)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('a frozen window ending just below the newest ring row fetches even near the live edge', () => {
|
|
259
|
+
// Ring rows newer than the window's to-edge consume ring budget a server
|
|
260
|
+
// window fetch would spend on OLDER rows the truncated ring dropped —
|
|
261
|
+
// wall-clock proximity of the to-edge proves nothing about coverage.
|
|
262
|
+
const busyRing: TimelineEvent[] = [
|
|
263
|
+
ev({ id: 'fresh', timestamp: new Date(NOW - 30_000).toISOString() }),
|
|
264
|
+
ev({ id: 'oldest', timestamp: new Date(oldestMs).toISOString() }),
|
|
265
|
+
]
|
|
266
|
+
expect(needsWindowFetch({ events: busyRing, truncated: true }, deepFromMs, NOW - 2 * 60_000, NOW)).toBe(true)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('a window reaching to/past the newest ring row stays on the ring, however deep its from-edge', () => {
|
|
270
|
+
// Every ring row falls inside such a window, and the ring is the server's
|
|
271
|
+
// newest-ringLimit answer — a window fetch would return the identical set,
|
|
272
|
+
// and arming it would re-key a full fetch on every live tick.
|
|
273
|
+
expect(needsWindowFetch({ events: ringEvents, truncated: true }, deepFromMs, NOW, NOW)).toBe(false)
|
|
274
|
+
expect(needsWindowFetch({ events: ringEvents, truncated: true }, deepFromMs, newestMs, NOW)).toBe(false)
|
|
275
|
+
// An idle ring whose newest row is old: the live selection's to-edge sits
|
|
276
|
+
// far above it and stays ring-served.
|
|
277
|
+
expect(needsWindowFetch({ events: ringEvents, truncated: true }, deepFromMs, NOW - 60_000, NOW)).toBe(false)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('rows stamped ahead of the client clock cannot re-arm the live selection', () => {
|
|
281
|
+
// Hub clock ahead: the newest ring row sits in the client's future. The
|
|
282
|
+
// newest-row edge caps at `now`, so a live to-edge at the clock still
|
|
283
|
+
// counts as reaching the ring's edge.
|
|
284
|
+
const skewedRing: TimelineEvent[] = [
|
|
285
|
+
ev({ id: 'future', timestamp: new Date(NOW + 3 * 60_000).toISOString() }),
|
|
286
|
+
ev({ id: 'oldest', timestamp: new Date(oldestMs).toISOString() }),
|
|
287
|
+
]
|
|
288
|
+
expect(needsWindowFetch({ events: skewedRing, truncated: true }, deepFromMs, NOW, NOW)).toBe(false)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('a delta row landing between live ticks cannot re-arm the live selection', () => {
|
|
292
|
+
// The live to-edge lags the clock by up to one coarse tick while delta
|
|
293
|
+
// merges keep pushing the newest ring row toward now; the tick-slack
|
|
294
|
+
// tolerance keeps that lag from re-keying a full-window fetch per merge.
|
|
295
|
+
const freshRing: TimelineEvent[] = [
|
|
296
|
+
ev({ id: 'justmerged', timestamp: new Date(NOW - 5_000).toISOString() }),
|
|
297
|
+
ev({ id: 'oldest', timestamp: new Date(oldestMs).toISOString() }),
|
|
298
|
+
]
|
|
299
|
+
expect(needsWindowFetch({ events: freshRing, truncated: true }, deepFromMs, NOW - 30_000, NOW)).toBe(false)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('an empty truncated ring fails toward fetching', () => {
|
|
303
|
+
expect(needsWindowFetch({ events: [], truncated: true }, deepFromMs, deepToMs, NOW)).toBe(true)
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
describe('rangeSpanMs (client-side preset resolution)', () => {
|
|
308
|
+
const CAP = 31 * 24 * 60 * 60 * 1000
|
|
309
|
+
it('resolves presets to their spans', () => {
|
|
310
|
+
expect(rangeSpanMs('24h', CAP)).toBe(24 * 60 * 60 * 1000)
|
|
311
|
+
expect(rangeSpanMs('7d', CAP)).toBe(7 * 24 * 60 * 60 * 1000)
|
|
312
|
+
})
|
|
313
|
+
it("falls back to the ring depth for 'all' and unset", () => {
|
|
314
|
+
expect(rangeSpanMs('all', CAP)).toBe(CAP)
|
|
315
|
+
expect(rangeSpanMs(undefined, CAP)).toBe(CAP)
|
|
316
|
+
})
|
|
317
|
+
it("preset arms return their nominal span — clamping is the caller's job", () => {
|
|
318
|
+
// A 30d preset against a 7d host returns 30d here; the use site clamps
|
|
319
|
+
// with Math.min against the ring depth (pinned indirectly by the hook).
|
|
320
|
+
const shallow = 7 * 24 * 60 * 60 * 1000
|
|
321
|
+
expect(rangeSpanMs('30d', shallow)).toBe(30 * 24 * 60 * 60 * 1000)
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
describe('retained ring fetch (the OSS-identical accumulate model)', () => {
|
|
326
|
+
const DAY = 24 * 60 * 60 * 1000
|
|
327
|
+
const CAP = 31 * DAY
|
|
328
|
+
const NOW = T0 + 40 * DAY
|
|
329
|
+
|
|
330
|
+
const line = (e: TimelineEvent) => JSON.stringify(e) + '\n'
|
|
331
|
+
const end = (over: Record<string, unknown> = {}) => JSON.stringify({ type: 'end', ...over }) + '\n'
|
|
332
|
+
|
|
333
|
+
function ring(events: TimelineEvent[], over: Partial<RetainedRing> = {}): RetainedRing {
|
|
334
|
+
return { events, coverage: [], truncated: false, cursor: '100', loadedAtMs: NOW, ...over }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
it('parses cursor/truncated from the end record and sends limit on the window fetch', async () => {
|
|
338
|
+
mockApiFetch.mockResolvedValue(
|
|
339
|
+
streamResponse([line(ev({ id: 'a' })), end({ cursor: '111', truncated: true })]),
|
|
340
|
+
)
|
|
341
|
+
const res = await fetchRetainedWindow(0, 1000, undefined, 50)
|
|
342
|
+
expect(mockApiFetch.mock.calls[0][0]).toContain('limit=50')
|
|
343
|
+
expect(res.cursor).toBe('111')
|
|
344
|
+
expect(res.truncated).toBe(true)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('sends the cursor on the delta fetch and surfaces more', async () => {
|
|
348
|
+
mockApiFetch.mockResolvedValue(streamResponse([end({ cursor: '222', more: true })]))
|
|
349
|
+
const res = await fetchRetainedDelta('111')
|
|
350
|
+
expect(mockApiFetch.mock.calls[0][0]).toContain('since=111')
|
|
351
|
+
expect(res.cursor).toBe('222')
|
|
352
|
+
expect(res.more).toBe(true)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('loads the full ring once, then accumulates deltas — including a LATE event', async () => {
|
|
356
|
+
const flags = new Set<string>()
|
|
357
|
+
// Full load: one event, cursor 100.
|
|
358
|
+
mockApiFetch.mockResolvedValueOnce(
|
|
359
|
+
streamResponse([line(ev({ id: 'e1', timestamp: new Date(NOW - DAY).toISOString() })), end({ cursor: '100' })]),
|
|
360
|
+
)
|
|
361
|
+
const first = await runRetainedRingFetch({ ringKey: 'k', cached: undefined, forceResync: flags, capMs: CAP, now: NOW })
|
|
362
|
+
expect(first.events.map((e) => e.id)).toEqual(['e1'])
|
|
363
|
+
expect(mockApiFetch.mock.calls[0][0]).toContain('from=')
|
|
364
|
+
|
|
365
|
+
// Delta: a brand-new event AND a late one (event time 3 days back) — both
|
|
366
|
+
// ride the ingestion-ordered poll and land in the ring.
|
|
367
|
+
mockApiFetch.mockResolvedValueOnce(
|
|
368
|
+
streamResponse([
|
|
369
|
+
line(ev({ id: 'e-new', timestamp: new Date(NOW).toISOString() })),
|
|
370
|
+
line(ev({ id: 'e-late', timestamp: new Date(NOW - 3 * DAY).toISOString() })),
|
|
371
|
+
end({ cursor: '200' }),
|
|
372
|
+
]),
|
|
373
|
+
)
|
|
374
|
+
const second = await runRetainedRingFetch({ ringKey: 'k', cached: first, forceResync: flags, capMs: CAP, now: NOW })
|
|
375
|
+
expect(mockApiFetch.mock.calls[1][0]).toContain('since=100')
|
|
376
|
+
expect(second.events.map((e) => e.id).sort()).toEqual(['e-late', 'e-new', 'e1'])
|
|
377
|
+
expect(second.cursor).toBe('200')
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
it('a delta revision replaces its cached id', async () => {
|
|
381
|
+
const flags = new Set<string>()
|
|
382
|
+
const cached = ring([ev({ id: 'e1', eventType: 'add', timestamp: new Date(NOW - DAY).toISOString() })])
|
|
383
|
+
mockApiFetch.mockResolvedValueOnce(
|
|
384
|
+
streamResponse([line(ev({ id: 'e1', eventType: 'update', timestamp: new Date(NOW - DAY).toISOString() })), end({ cursor: '200' })]),
|
|
385
|
+
)
|
|
386
|
+
const out = await runRetainedRingFetch({ ringKey: 'k', cached, forceResync: flags, capMs: CAP, now: NOW })
|
|
387
|
+
expect(out.events).toHaveLength(1)
|
|
388
|
+
expect(out.events[0].eventType).toBe('update')
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('a no-op delta returns the cached reference (no re-render)', async () => {
|
|
392
|
+
const flags = new Set<string>()
|
|
393
|
+
const cached = ring([ev({ id: 'e1' })])
|
|
394
|
+
// No rows, cursor unchanged — the idle-cluster steady state.
|
|
395
|
+
mockApiFetch.mockResolvedValueOnce(streamResponse([end({ cursor: '100' })]))
|
|
396
|
+
const out = await runRetainedRingFetch({ ringKey: 'k', cached, forceResync: flags, capMs: CAP, now: NOW })
|
|
397
|
+
expect(out).toBe(cached)
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
it('an empty delta with a moved cursor commits the cursor WITH the data', async () => {
|
|
401
|
+
const flags = new Set<string>()
|
|
402
|
+
const loadedAt = NOW - 1000
|
|
403
|
+
const cached = ring([ev({ id: 'e1', timestamp: new Date(NOW - DAY).toISOString() })], { loadedAtMs: loadedAt })
|
|
404
|
+
mockApiFetch.mockResolvedValueOnce(streamResponse([end({ cursor: '150' })]))
|
|
405
|
+
const out = await runRetainedRingFetch({ ringKey: 'k', cached, forceResync: flags, capMs: CAP, now: NOW })
|
|
406
|
+
// The advanced cursor rides the same commit as the events it describes —
|
|
407
|
+
// a discarded fetch discards both, so they can never diverge.
|
|
408
|
+
expect(out).not.toBe(cached)
|
|
409
|
+
expect(out.cursor).toBe('150')
|
|
410
|
+
expect(out.events.map((e) => e.id)).toEqual(['e1'])
|
|
411
|
+
// Delta commits must PRESERVE the full-load clock — resetting it to `now`
|
|
412
|
+
// would silently disable the hourly anti-entropy on any active cluster.
|
|
413
|
+
expect(out.loadedAtMs).toBe(loadedAt)
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it('a ring loaded "in the future" (backward clock step) resyncs instead of suspending', async () => {
|
|
417
|
+
const flags = new Set<string>()
|
|
418
|
+
const cached = ring([ev({ id: 'e1', timestamp: new Date(NOW - DAY).toISOString() })], { loadedAtMs: NOW + 10 * 60 * 1000 })
|
|
419
|
+
mockApiFetch.mockResolvedValueOnce(
|
|
420
|
+
streamResponse([line(ev({ id: 'e-resynced', timestamp: new Date(NOW).toISOString() })), end({ cursor: '700' })]),
|
|
421
|
+
)
|
|
422
|
+
const out = await runRetainedRingFetch({ ringKey: 'k', cached, forceResync: flags, capMs: CAP, now: NOW })
|
|
423
|
+
expect(mockApiFetch.mock.calls[0][0]).toContain('from=')
|
|
424
|
+
expect(out.loadedAtMs).toBe(NOW)
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
it('prunes events older than the retention depth on merge, keeping the skew-slack band', async () => {
|
|
428
|
+
const flags = new Set<string>()
|
|
429
|
+
const cached = ring([
|
|
430
|
+
ev({ id: 'e-ancient', timestamp: new Date(NOW - CAP - DAY).toISOString() }),
|
|
431
|
+
// Inside the clock-skew slack band just past the depth: a client clock
|
|
432
|
+
// ahead of the hub must not prune what the hub still retains.
|
|
433
|
+
ev({ id: 'e-slack', timestamp: new Date(NOW - CAP - 60 * 1000).toISOString() }),
|
|
434
|
+
ev({ id: 'e-kept', timestamp: new Date(NOW - DAY).toISOString() }),
|
|
435
|
+
])
|
|
436
|
+
mockApiFetch.mockResolvedValueOnce(
|
|
437
|
+
streamResponse([line(ev({ id: 'e-new', timestamp: new Date(NOW).toISOString() })), end({ cursor: '200' })]),
|
|
438
|
+
)
|
|
439
|
+
const out = await runRetainedRingFetch({ ringKey: 'k', cached, forceResync: flags, capMs: CAP, now: NOW })
|
|
440
|
+
expect(out.events.map((e) => e.id).sort()).toEqual(['e-kept', 'e-new', 'e-slack'])
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
it('a capped delta page (more) pages forward within one fetch', async () => {
|
|
444
|
+
const flags = new Set<string>()
|
|
445
|
+
const cached = ring([ev({ id: 'e1', timestamp: new Date(NOW - DAY).toISOString() })])
|
|
446
|
+
mockApiFetch
|
|
447
|
+
.mockResolvedValueOnce(streamResponse([line(ev({ id: 'p1', timestamp: new Date(NOW).toISOString() })), end({ cursor: '150', more: true })]))
|
|
448
|
+
.mockResolvedValueOnce(streamResponse([line(ev({ id: 'p2', timestamp: new Date(NOW).toISOString() })), end({ cursor: '200' })]))
|
|
449
|
+
const out = await runRetainedRingFetch({ ringKey: 'k', cached, forceResync: flags, capMs: CAP, now: NOW })
|
|
450
|
+
expect(mockApiFetch).toHaveBeenCalledTimes(2)
|
|
451
|
+
expect(mockApiFetch.mock.calls[1][0]).toContain('since=150')
|
|
452
|
+
expect(out.events.map((e) => e.id).sort()).toEqual(['e1', 'p1', 'p2'])
|
|
453
|
+
expect(out.cursor).toBe('200')
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
it('a rejected cursor (400) resyncs with a full ring load', async () => {
|
|
457
|
+
const flags = new Set<string>()
|
|
458
|
+
const cached = ring([ev({ id: 'e-stale' })])
|
|
459
|
+
mockApiFetch
|
|
460
|
+
.mockResolvedValueOnce({
|
|
461
|
+
ok: false,
|
|
462
|
+
status: 400,
|
|
463
|
+
json: () => Promise.resolve({ error: 'invalid since cursor' }),
|
|
464
|
+
} as unknown as Response)
|
|
465
|
+
.mockResolvedValueOnce(streamResponse([line(ev({ id: 'e-fresh' })), end({ cursor: '300' })]))
|
|
466
|
+
const out = await runRetainedRingFetch({ ringKey: 'k', cached, forceResync: flags, capMs: CAP, now: NOW })
|
|
467
|
+
expect(out.events.map((e) => e.id)).toEqual(['e-fresh'])
|
|
468
|
+
expect(out.cursor).toBe('300')
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
it('caps accumulated growth at the ring limit, dropping the oldest and flagging truncated', async () => {
|
|
472
|
+
const flags = new Set<string>()
|
|
473
|
+
// A ring already at the cap, oldest-last after sort.
|
|
474
|
+
const full: TimelineEvent[] = Array.from({ length: RETAINED_RING_LIMIT }, (_, i) =>
|
|
475
|
+
ev({ id: `e${i}`, timestamp: new Date(NOW - DAY - i * 1000).toISOString() }),
|
|
476
|
+
)
|
|
477
|
+
const cached = ring(full)
|
|
478
|
+
mockApiFetch.mockResolvedValueOnce(
|
|
479
|
+
streamResponse([
|
|
480
|
+
line(ev({ id: 'e-newest', timestamp: new Date(NOW).toISOString() })),
|
|
481
|
+
end({ cursor: '200' }),
|
|
482
|
+
]),
|
|
483
|
+
)
|
|
484
|
+
const out = await runRetainedRingFetch({ ringKey: 'k', cached, forceResync: flags, capMs: CAP, now: NOW })
|
|
485
|
+
expect(out.events).toHaveLength(RETAINED_RING_LIMIT)
|
|
486
|
+
expect(out.events[0].id).toBe('e-newest')
|
|
487
|
+
// The OLDEST row fell off the ring, and the drop is flagged, not silent.
|
|
488
|
+
expect(out.events.some((e) => e.id === `e${RETAINED_RING_LIMIT - 1}`)).toBe(false)
|
|
489
|
+
expect(out.truncated).toBe(true)
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
// The local source shares this implementation with a smaller ceiling (its
|
|
493
|
+
// binary pages at 10k); the cap and the window-load limit must both follow
|
|
494
|
+
// the configured ringLimit, not the retained default.
|
|
495
|
+
it('honors a per-source ringLimit for the cap and the window-load limit', async () => {
|
|
496
|
+
const flags = new Set<string>()
|
|
497
|
+
const cached = ring([
|
|
498
|
+
ev({ id: 'old-1', timestamp: new Date(NOW - DAY - 1000).toISOString() }),
|
|
499
|
+
ev({ id: 'old-2', timestamp: new Date(NOW - DAY - 2000).toISOString() }),
|
|
500
|
+
ev({ id: 'old-3', timestamp: new Date(NOW - DAY - 3000).toISOString() }),
|
|
501
|
+
])
|
|
502
|
+
mockApiFetch.mockResolvedValueOnce(
|
|
503
|
+
streamResponse([line(ev({ id: 'new-1', timestamp: new Date(NOW).toISOString() })), end({ cursor: '200' })]),
|
|
504
|
+
)
|
|
505
|
+
const out = await runRetainedRingFetch({ ringKey: 'k', cached, forceResync: flags, capMs: CAP, now: NOW, ringLimit: 3 })
|
|
506
|
+
expect(out.events.map((e) => e.id)).toEqual(['new-1', 'old-1', 'old-2'])
|
|
507
|
+
expect(out.truncated).toBe(true)
|
|
508
|
+
|
|
509
|
+
// A full load sends the configured limit on the wire.
|
|
510
|
+
const flags2 = new Set<string>()
|
|
511
|
+
mockApiFetch.mockResolvedValueOnce(streamResponse([end({ cursor: '1' })]))
|
|
512
|
+
await runRetainedRingFetch({ ringKey: 'k2', cached: undefined, forceResync: flags2, capMs: CAP, now: NOW, ringLimit: 3 })
|
|
513
|
+
expect(mockApiFetch.mock.calls.at(-1)?.[0]).toContain('limit=3')
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
// Unbounded depth (a local store configured to retain forever): the full
|
|
517
|
+
// load starts at epoch zero and delta merges never age-prune.
|
|
518
|
+
it('treats an unset capMs as unbounded depth', async () => {
|
|
519
|
+
const flags = new Set<string>()
|
|
520
|
+
mockApiFetch.mockResolvedValueOnce(streamResponse([end({ cursor: '5' })]))
|
|
521
|
+
await runRetainedRingFetch({ ringKey: 'k', cached: undefined, forceResync: flags, now: NOW })
|
|
522
|
+
expect(mockApiFetch.mock.calls.at(-1)?.[0]).toContain('from=0')
|
|
523
|
+
|
|
524
|
+
const ancient = ev({ id: 'e-ancient', timestamp: new Date(NOW - 400 * DAY).toISOString() })
|
|
525
|
+
const cached = ring([ancient])
|
|
526
|
+
mockApiFetch.mockResolvedValueOnce(streamResponse([line(ev({ id: 'e-new', timestamp: new Date(NOW).toISOString() })), end({ cursor: '9' })]))
|
|
527
|
+
const out = await runRetainedRingFetch({ ringKey: 'k', cached, forceResync: flags, now: NOW })
|
|
528
|
+
expect(out.events.some((e) => e.id === 'e-ancient')).toBe(true)
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
// The local source keeps its historical 5-minute anti-entropy cadence; a
|
|
532
|
+
// per-source resyncMs below the ring age must force a full reload.
|
|
533
|
+
it('honors a per-source resync interval', async () => {
|
|
534
|
+
const flags = new Set<string>()
|
|
535
|
+
const cached = ring([ev({ id: 'e1' })])
|
|
536
|
+
cached.loadedAtMs = NOW - 6 * 60 * 1000
|
|
537
|
+
mockApiFetch.mockResolvedValueOnce(streamResponse([line(ev({ id: 'e2' })), end({ cursor: '9' })]))
|
|
538
|
+
await runRetainedRingFetch({
|
|
539
|
+
ringKey: 'k', cached, forceResync: flags, capMs: CAP, now: NOW,
|
|
540
|
+
resyncMs: 5 * 60 * 1000,
|
|
541
|
+
})
|
|
542
|
+
// 6 minutes old with a 5-minute cadence: a FULL window load, not a delta.
|
|
543
|
+
expect(mockApiFetch.mock.calls.at(-1)?.[0]).toContain('from=')
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
// The ring is row-capped, so a namespace-scoped consumer must scope the
|
|
547
|
+
// server query itself — client-side filtering alone would let other
|
|
548
|
+
// namespaces' events consume the cap.
|
|
549
|
+
it('sends the namespace scope on window loads and delta polls', async () => {
|
|
550
|
+
const flags = new Set<string>()
|
|
551
|
+
mockApiFetch.mockResolvedValueOnce(streamResponse([end({ cursor: '5' })]))
|
|
552
|
+
const first = await runRetainedRingFetch({
|
|
553
|
+
ringKey: 'k', cached: undefined, forceResync: flags, capMs: CAP, now: NOW,
|
|
554
|
+
namespaces: ['team-a', 'team-b'],
|
|
555
|
+
})
|
|
556
|
+
expect(mockApiFetch.mock.calls.at(-1)?.[0]).toContain('namespaces=team-a%2Cteam-b')
|
|
557
|
+
|
|
558
|
+
mockApiFetch.mockResolvedValueOnce(streamResponse([end({ cursor: '6' })]))
|
|
559
|
+
await runRetainedRingFetch({
|
|
560
|
+
ringKey: 'k', cached: first, forceResync: flags, capMs: CAP, now: NOW,
|
|
561
|
+
namespaces: ['team-a', 'team-b'],
|
|
562
|
+
})
|
|
563
|
+
expect(mockApiFetch.mock.calls.at(-1)?.[0]).toContain('since=5')
|
|
564
|
+
expect(mockApiFetch.mock.calls.at(-1)?.[0]).toContain('namespaces=team-a%2Cteam-b')
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
it('a hub without a delta cursor turns polls into no-ops instead of full reloads', async () => {
|
|
568
|
+
const flags = new Set<string>()
|
|
569
|
+
// Full load whose end record has NO cursor (pre-delta hub).
|
|
570
|
+
mockApiFetch.mockResolvedValueOnce(
|
|
571
|
+
streamResponse([line(ev({ id: 'e1', timestamp: new Date(NOW).toISOString() })), end()]),
|
|
572
|
+
)
|
|
573
|
+
const first = await runRetainedRingFetch({ ringKey: 'k', cached: undefined, forceResync: flags, capMs: CAP, now: NOW })
|
|
574
|
+
expect(first.events.map((e) => e.id)).toEqual(['e1'])
|
|
575
|
+
// Next poll: no network at all — the cached ring is returned as-is.
|
|
576
|
+
const second = await runRetainedRingFetch({ ringKey: 'k', cached: first, forceResync: flags, capMs: CAP, now: NOW })
|
|
577
|
+
expect(second).toBe(first)
|
|
578
|
+
expect(mockApiFetch).toHaveBeenCalledTimes(1)
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
it('a stale ring resyncs with a full reload past the anti-entropy window', async () => {
|
|
582
|
+
// loadedAtMs two hours back: cursor is valid, but the resync clock is due —
|
|
583
|
+
// the poll must take the FULL path (refreshing coverage + truncated).
|
|
584
|
+
const flags = new Set<string>()
|
|
585
|
+
const cached = ring(
|
|
586
|
+
[ev({ id: 'e-old-copy', timestamp: new Date(NOW - DAY).toISOString() })],
|
|
587
|
+
{ loadedAtMs: NOW - 2 * 60 * 60 * 1000 },
|
|
588
|
+
)
|
|
589
|
+
mockApiFetch.mockResolvedValueOnce(
|
|
590
|
+
streamResponse([line(ev({ id: 'e-fresh', timestamp: new Date(NOW).toISOString() })), end({ cursor: '900' })]),
|
|
591
|
+
)
|
|
592
|
+
const out = await runRetainedRingFetch({ ringKey: 'k', cached, forceResync: flags, capMs: CAP, now: NOW })
|
|
593
|
+
expect(mockApiFetch.mock.calls[0][0]).toContain('from=')
|
|
594
|
+
expect(mockApiFetch.mock.calls[0][0]).not.toContain('since=')
|
|
595
|
+
expect(out.events.map((e) => e.id)).toEqual(['e-fresh'])
|
|
596
|
+
expect(out.cursor).toBe('900')
|
|
597
|
+
expect(out.loadedAtMs).toBe(NOW)
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
it('a manual refresh flag forces a full reload and is consumed', async () => {
|
|
601
|
+
const flags = new Set<string>(['k'])
|
|
602
|
+
const cached = ring([ev({ id: 'e1', timestamp: new Date(NOW - DAY).toISOString() })])
|
|
603
|
+
mockApiFetch.mockResolvedValueOnce(
|
|
604
|
+
streamResponse([line(ev({ id: 'e-resynced', timestamp: new Date(NOW).toISOString() })), end({ cursor: '500' })]),
|
|
605
|
+
)
|
|
606
|
+
const out = await runRetainedRingFetch({ ringKey: 'k', cached, forceResync: flags, capMs: CAP, now: NOW })
|
|
607
|
+
expect(mockApiFetch.mock.calls[0][0]).toContain('from=')
|
|
608
|
+
expect(out.events.map((e) => e.id)).toEqual(['e-resynced'])
|
|
609
|
+
expect(flags.has('k')).toBe(false)
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
it('the initial window extends past the client clock by the skew slack', async () => {
|
|
613
|
+
// A client clock behind the hub must not open a permanent hole at the live
|
|
614
|
+
// edge: to > now, from slides back by the same slack to keep the span.
|
|
615
|
+
const flags = new Set<string>()
|
|
616
|
+
mockApiFetch.mockResolvedValueOnce(streamResponse([end({ cursor: '1' })]))
|
|
617
|
+
await runRetainedRingFetch({ ringKey: 'k', cached: undefined, forceResync: flags, capMs: CAP, now: NOW })
|
|
618
|
+
const url = String(mockApiFetch.mock.calls[0][0])
|
|
619
|
+
const from = Number(/from=(\d+)/.exec(url)?.[1])
|
|
620
|
+
const to = Number(/to=(\d+)/.exec(url)?.[1])
|
|
621
|
+
expect(to).toBeGreaterThan(NOW)
|
|
622
|
+
expect(to - from).toBe(CAP)
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
// The frozen-window fetch reuses fetchRetainedWindow with the hook's exact
|
|
626
|
+
// arguments: the selection's [from,to], the source's ringLimit, and the
|
|
627
|
+
// server-side namespace scope.
|
|
628
|
+
it('a frozen-window fetch carries from/to/limit/namespaces on the wire and surfaces truncated', async () => {
|
|
629
|
+
mockApiFetch.mockResolvedValue(
|
|
630
|
+
streamResponse([
|
|
631
|
+
line(ev({ id: 'deep', timestamp: new Date(T0).toISOString() })),
|
|
632
|
+
end({ truncated: true }),
|
|
633
|
+
]),
|
|
634
|
+
)
|
|
635
|
+
const res = await fetchRetainedWindow(T0, T0 + 60_000, undefined, 10_000, ['team-a', 'team-b'])
|
|
636
|
+
const url = String(mockApiFetch.mock.calls[0][0])
|
|
637
|
+
expect(url).toContain(`from=${T0}`)
|
|
638
|
+
expect(url).toContain(`to=${T0 + 60_000}`)
|
|
639
|
+
expect(url).toContain('limit=10000')
|
|
640
|
+
expect(url).toContain('namespaces=team-a%2Cteam-b')
|
|
641
|
+
expect(res.events.map((e) => e.id)).toEqual(['deep'])
|
|
642
|
+
// The window response's own truncated flag is what the hook surfaces while
|
|
643
|
+
// the window serves — a row-capped frozen window must not read as complete.
|
|
644
|
+
expect(res.truncated).toBe(true)
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
it('a non-400 delta failure propagates and keeps the cursor for the next poll', async () => {
|
|
648
|
+
const flags = new Set<string>()
|
|
649
|
+
const cached = ring([ev({ id: 'e1' })])
|
|
650
|
+
mockApiFetch.mockResolvedValueOnce({
|
|
651
|
+
ok: false,
|
|
652
|
+
json: () => Promise.resolve({ error: 'boom' }),
|
|
653
|
+
status: 503,
|
|
654
|
+
} as unknown as Response)
|
|
655
|
+
await expect(
|
|
656
|
+
runRetainedRingFetch({ ringKey: 'k', cached, forceResync: flags, capMs: CAP, now: NOW }),
|
|
657
|
+
).rejects.toBeInstanceOf(ApiError)
|
|
658
|
+
// cursor is intact on the cached ring — nothing was committed
|
|
216
659
|
})
|
|
217
660
|
})
|