@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.8.13",
3
+ "version": "1.9.0",
4
4
  "description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
5
5
  "repository": {
6
6
  "type": "git",
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). The graph surfaces DANGER only
1516
- // (warnings would turn a dense graph into a heatmap); the node component reads
1517
- // data.auditDanger. Re-runs only when findings change, and copies nodes only
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 client module so a
4
- // test can hand fetchRetainedWindow an arbitrary streamed body. ApiError is only
5
- // hit on the non-ok branch (not exercised here) but must exist for module load.
6
- vi.mock('./client', () => ({
7
- apiFetch: vi.fn(),
8
- useChanges: vi.fn(),
9
- ApiError: class ApiError extends Error {
10
- status: number
11
- constructor(message: string, status: number) {
12
- super(message)
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
- LIVE_WINDOW_MS,
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
- describe('cross-package live-window invariant', () => {
214
- it('quantization step stays under the live poll window so no fetch hole opens', () => {
215
- expect(BASE_QUANTIZE_STEP_MS).toBeLessThan(LIVE_WINDOW_MS)
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
  })