doover-js 0.6.9 → 0.7.1

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.
@@ -3,6 +3,13 @@ import type { CreateMessageRequest, DataSeries, MessageStructure, UpdateMessageR
3
3
  import type { DooverRequestOptions } from "../client/request-options";
4
4
  export interface ListMessagesParams {
5
5
  before?: string;
6
+ /**
7
+ * Lower-bound cursor (exclusive). With `before` set (or defaulted) it merely
8
+ * floors the window. On its own — `after` set, `before` omitted — it pages
9
+ * *forward*: the server returns the `limit` **oldest** messages strictly
10
+ * newer than the cursor, oldest-first. This is the only way to walk history
11
+ * upward toward "now".
12
+ */
6
13
  after?: string;
7
14
  limit?: number;
8
15
  field_name?: string[];
@@ -5,15 +5,31 @@ const snowflake_1 = require("../utils/snowflake");
5
5
  const _args_1 = require("./_args");
6
6
  function resolveListMessagesParams(params) {
7
7
  const order = params?.order ?? "desc";
8
+ const hasAfter = params?.after !== undefined;
9
+ const hasBefore = params?.before !== undefined;
10
+ // The server keys pagination on whichever bound is sent: `before` walks
11
+ // newest-first downward, `after` alone walks oldest-first upward (the N
12
+ // oldest messages strictly newer than the cursor). Sending `after` without
13
+ // `before` is the only way to page forward; sending both, or neither, keeps
14
+ // the newest-first behaviour (defaulting `before` to "now" when unbounded).
15
+ if (hasAfter && !hasBefore) {
16
+ const query = {
17
+ after: params.after,
18
+ limit: params?.limit ?? 10,
19
+ };
20
+ if (params?.field_name !== undefined)
21
+ query.field_name = params.field_name;
22
+ return { query, order, serverAscending: true };
23
+ }
8
24
  const query = {
9
25
  before: params?.before ?? (0, snowflake_1.generateSnowflakeIdAtTime)(new Date()),
10
26
  limit: params?.limit ?? 10,
11
27
  };
12
- if (params?.after !== undefined)
28
+ if (hasAfter)
13
29
  query.after = params.after;
14
30
  if (params?.field_name !== undefined)
15
31
  query.field_name = params.field_name;
16
- return { query, order };
32
+ return { query, order, serverAscending: false };
17
33
  }
18
34
  class MessagesApi {
19
35
  constructor(rest) {
@@ -24,10 +40,13 @@ class MessagesApi {
24
40
  return this._listMessages(agentId, channelName, options);
25
41
  }
26
42
  async _listMessages(agentId, channelName, params) {
27
- const { query, order } = resolveListMessagesParams(params);
43
+ const { query, order, serverAscending } = resolveListMessagesParams(params);
28
44
  const response = await this.rest.get(`/agents/${agentId}/channels/${channelName}/messages`, query);
29
45
  const stamped = response.map(snowflake_1.addTimestampToMessage);
30
- return order === "asc" ? [...stamped].reverse() : stamped;
46
+ // The server returns newest-first for `before` and oldest-first for a bare
47
+ // `after`; reverse only when that native order differs from what was asked.
48
+ const wantAscending = order === "asc";
49
+ return serverAscending === wantAscending ? stamped : [...stamped].reverse();
31
50
  }
32
51
  async postMessage(...args) {
33
52
  if (typeof args[0] === "string") {
@@ -0,0 +1,87 @@
1
+ import type { MessageStructure } from "../types/common";
2
+ /**
3
+ * A per-channel cache of *proven-complete* id ranges.
4
+ *
5
+ * `useChannelMessages` models history as a chain of cursors: each page's oldest
6
+ * id is the next page's `before`. That chain is anchored at its first cursor, so
7
+ * two windows over the same channel are two unrelated chains — re-anchoring
8
+ * always refetches, even over data already held.
9
+ *
10
+ * This store keeps the fact the chain throws away. A response to
11
+ * `before=X, limit=N` is "the N newest messages strictly older than X", so a
12
+ * full page proves that *every* message in `[oldest.id, X)` is in hand. History
13
+ * is append-only, so that stays true forever. Overlapping windows then merge
14
+ * into one segment instead of duplicating, and revisiting a range costs nothing.
15
+ *
16
+ * The store sits under the query, not beside it: the chains stay as they are and
17
+ * remain the cache React Query sees, while this layer removes the network calls
18
+ * underneath them.
19
+ *
20
+ * Ids are decimal snowflakes of varying length, so every comparison goes through
21
+ * `BigInt` — string ordering would put "500" after "4000".
22
+ */
23
+ /** A contiguous range of history we can prove we hold in full. */
24
+ export interface RangeSegment {
25
+ /** Id of the oldest message held. Meaningless when `atStart` (coverage runs to -inf). */
26
+ lo: bigint;
27
+ /** Exclusive upper bound of proven coverage. */
28
+ hi: bigint;
29
+ /** No messages exist below `lo` — the channel (or its retention window) starts here. */
30
+ atStart: boolean;
31
+ /**
32
+ * `hi` tracks the live feed. Only true while a fetch has proven coverage up to
33
+ * roughly now AND the socket has not dropped since — see `sealTips`.
34
+ */
35
+ live: boolean;
36
+ /** Ascending by id. */
37
+ messages: MessageStructure[];
38
+ }
39
+ export declare class ChannelRangeStore {
40
+ /** Disjoint, ascending by `lo`. */
41
+ private segments;
42
+ /**
43
+ * Answer "the newest `limit` messages strictly older than `before`" from cache,
44
+ * or `undefined` when the range isn't proven and the caller must fetch.
45
+ *
46
+ * Returns ascending by id, matching `listMessages({ order: "asc" })`.
47
+ */
48
+ read(before: bigint, limit: number): MessageStructure[] | undefined;
49
+ /**
50
+ * Record a fetched page. `before` is the cursor actually sent and `limit` the
51
+ * one actually requested — a short page proves the range bottoms out, which is
52
+ * only sound because the server rejects an over-cap `limit` rather than
53
+ * silently clamping it.
54
+ */
55
+ record(params: {
56
+ before: bigint;
57
+ limit: number;
58
+ page: MessageStructure[];
59
+ /** Epoch ms the request was issued, for deciding whether this segment is live. */
60
+ at?: number;
61
+ }): void;
62
+ /**
63
+ * Extend the live segment with a pushed message. Ignored unless a segment is
64
+ * currently live: outside that, the message and the segment may have a gap
65
+ * between them, and claiming coverage across it would hide messages forever.
66
+ */
67
+ recordLive(message: MessageStructure): void;
68
+ /**
69
+ * Stop trusting the live feed to extend coverage — call when the socket drops.
70
+ * Messages sent while disconnected would leave a hole, so segments go back to
71
+ * proving coverage by fetching.
72
+ */
73
+ sealTips(): void;
74
+ /** Test/debug view. */
75
+ snapshot(): RangeSegment[];
76
+ clear(): void;
77
+ /** Union `incoming` with every segment it overlaps or merely touches. */
78
+ private merge;
79
+ }
80
+ /**
81
+ * Stores are per *stream* — agent, channel, and any `fields` filter, which
82
+ * returns a different set of messages. Deliberately NOT per anchor: sharing
83
+ * across anchors is the entire point.
84
+ */
85
+ export declare function getChannelRangeStore(key: string): ChannelRangeStore;
86
+ /** Drop every store. Tests only — there is no eviction in normal operation. */
87
+ export declare function resetChannelRangeStores(): void;
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ChannelRangeStore = void 0;
4
+ exports.getChannelRangeStore = getChannelRangeStore;
5
+ exports.resetChannelRangeStores = resetChannelRangeStores;
6
+ /**
7
+ * How close to "now" a fetch's `before` must be for its segment to accept live
8
+ * pushes. Beyond this the feed and the segment may not be contiguous.
9
+ */
10
+ const LIVE_PROOF_TOLERANCE_MS = 60000;
11
+ const idOf = (message) => BigInt(message.id);
12
+ function sortById(messages) {
13
+ return [...messages].sort((a, b) => (idOf(a) < idOf(b) ? -1 : idOf(a) > idOf(b) ? 1 : 0));
14
+ }
15
+ function dedupeById(messages) {
16
+ const seen = new Map();
17
+ // Later wins: a live push or a refetch supersedes an older copy
18
+ for (const message of messages)
19
+ seen.set(message.id, message);
20
+ return sortById([...seen.values()]);
21
+ }
22
+ class ChannelRangeStore {
23
+ constructor() {
24
+ /** Disjoint, ascending by `lo`. */
25
+ this.segments = [];
26
+ }
27
+ /**
28
+ * Answer "the newest `limit` messages strictly older than `before`" from cache,
29
+ * or `undefined` when the range isn't proven and the caller must fetch.
30
+ *
31
+ * Returns ascending by id, matching `listMessages({ order: "asc" })`.
32
+ */
33
+ read(before, limit) {
34
+ const segment = this.segments.find((s) => before <= s.hi && (s.atStart || before >= s.lo));
35
+ if (!segment)
36
+ return undefined;
37
+ const older = segment.messages.filter((m) => idOf(m) < before);
38
+ if (older.length >= limit)
39
+ return older.slice(older.length - limit);
40
+ // Short of `limit` only tells us the range is exhausted if nothing precedes it
41
+ return segment.atStart ? older : undefined;
42
+ }
43
+ /**
44
+ * Record a fetched page. `before` is the cursor actually sent and `limit` the
45
+ * one actually requested — a short page proves the range bottoms out, which is
46
+ * only sound because the server rejects an over-cap `limit` rather than
47
+ * silently clamping it.
48
+ */
49
+ record(params) {
50
+ const { before, limit, page } = params;
51
+ const at = params.at ?? Date.now();
52
+ const messages = sortById(page);
53
+ const atStart = messages.length < limit;
54
+ const lo = messages.length > 0 ? idOf(messages[0]) : before;
55
+ const live = timestampOf(before) >= at - LIVE_PROOF_TOLERANCE_MS;
56
+ this.merge({ lo, hi: before, atStart, live, messages });
57
+ }
58
+ /**
59
+ * Extend the live segment with a pushed message. Ignored unless a segment is
60
+ * currently live: outside that, the message and the segment may have a gap
61
+ * between them, and claiming coverage across it would hide messages forever.
62
+ */
63
+ recordLive(message) {
64
+ const id = idOf(message);
65
+ const tip = this.segments.find((s) => s.live);
66
+ if (!tip || id < tip.lo)
67
+ return;
68
+ tip.messages = dedupeById([...tip.messages, message]);
69
+ if (id >= tip.hi)
70
+ tip.hi = id + 1n;
71
+ }
72
+ /**
73
+ * Stop trusting the live feed to extend coverage — call when the socket drops.
74
+ * Messages sent while disconnected would leave a hole, so segments go back to
75
+ * proving coverage by fetching.
76
+ */
77
+ sealTips() {
78
+ for (const segment of this.segments)
79
+ segment.live = false;
80
+ }
81
+ /** Test/debug view. */
82
+ snapshot() {
83
+ return this.segments.map((s) => ({ ...s, messages: [...s.messages] }));
84
+ }
85
+ clear() {
86
+ this.segments = [];
87
+ }
88
+ /** Union `incoming` with every segment it overlaps or merely touches. */
89
+ merge(incoming) {
90
+ const overlapping = [];
91
+ const rest = [];
92
+ for (const segment of this.segments) {
93
+ // Touching counts: [a,b) and [b,c) are both proven, so [a,c) is too
94
+ const disjoint = segment.hi < incoming.lo || segment.lo > incoming.hi;
95
+ (disjoint ? rest : overlapping).push(segment);
96
+ }
97
+ const merged = overlapping.reduce((acc, s) => ({
98
+ lo: s.lo < acc.lo ? s.lo : acc.lo,
99
+ hi: s.hi > acc.hi ? s.hi : acc.hi,
100
+ atStart: acc.atStart || s.atStart,
101
+ live: acc.live || s.live,
102
+ messages: [...acc.messages, ...s.messages],
103
+ }), incoming);
104
+ merged.messages = dedupeById(merged.messages);
105
+ if (merged.atStart && merged.messages.length > 0) {
106
+ merged.lo = idOf(merged.messages[0]);
107
+ }
108
+ this.segments = [...rest, merged].sort((a, b) => (a.lo < b.lo ? -1 : a.lo > b.lo ? 1 : 0));
109
+ }
110
+ }
111
+ exports.ChannelRangeStore = ChannelRangeStore;
112
+ const SNOWFLAKE_EPOCH_MS = 1735689600000n;
113
+ /** Epoch ms encoded in a snowflake, without going through the string round-trip. */
114
+ function timestampOf(id) {
115
+ return Number((id >> 22n) + SNOWFLAKE_EPOCH_MS);
116
+ }
117
+ const stores = new Map();
118
+ /**
119
+ * Stores are per *stream* — agent, channel, and any `fields` filter, which
120
+ * returns a different set of messages. Deliberately NOT per anchor: sharing
121
+ * across anchors is the entire point.
122
+ */
123
+ function getChannelRangeStore(key) {
124
+ let store = stores.get(key);
125
+ if (!store) {
126
+ store = new ChannelRangeStore();
127
+ stores.set(key, store);
128
+ }
129
+ return store;
130
+ }
131
+ /** Drop every store. Tests only — there is no eviction in normal operation. */
132
+ function resetChannelRangeStores() {
133
+ stores.clear();
134
+ }
@@ -2,7 +2,13 @@ import { type InfiniteData, type UseInfiniteQueryResult } from "@tanstack/react-
2
2
  import type { MessageStructure } from "../types/common";
3
3
  import type { ChannelIdentifier } from "../types/viewer";
4
4
  import { type OfflineStatusSnapshot } from "./useOfflineStatus";
5
- export declare function channelMessagesQueryKey(agentId: string | undefined, channelName: string | undefined, fields?: readonly string[], sources?: string[]): readonly ["doover", "agent", string | undefined, "channel", string | undefined, "messages", "src", string] | readonly ["doover", "agent", string | undefined, "channel", string | undefined, "messages", "src", string, {
5
+ export declare function channelMessagesQueryKey(agentId: string | undefined, channelName: string | undefined, fields?: readonly string[], sources?: string[], anchor?: string): readonly ["doover", "agent", string | undefined, "channel", string | undefined, "messages", "src", string] | readonly ["doover", "agent", string | undefined, "channel", string | undefined, "messages", "src", string, {
6
+ readonly anchor: string;
7
+ }] | readonly ["doover", "agent", string | undefined, "channel", string | undefined, "messages", "src", string, {
8
+ readonly fields: string[];
9
+ }] | readonly ["doover", "agent", string | undefined, "channel", string | undefined, "messages", "src", string, {
10
+ readonly anchor: string;
11
+ }, {
6
12
  readonly fields: string[];
7
13
  }];
8
14
  export interface UseChannelMessagesOptions {
@@ -22,6 +28,20 @@ export interface UseChannelMessagesOptions {
22
28
  * snowflake so server-stamped messages don't get missed).
23
29
  */
24
30
  initialBefore?: string;
31
+ /**
32
+ * Pin the window to a point in history (snowflake id), for "jump to this
33
+ * date" navigation. Changing it re-anchors and refetches.
34
+ *
35
+ * Distinct from `initialBefore`, which is a clock-skew seed regenerated per
36
+ * mount and therefore must NOT dimension the cache key. An anchor identifies
37
+ * a stream the caller means to look at, so it does. Pass a stable value — a
38
+ * per-render `now` would refetch on every render.
39
+ *
40
+ * Cheaper than it looks: anchored windows share the range cache with every
41
+ * other window on the channel, so re-anchoring over already-fetched history
42
+ * costs no requests.
43
+ */
44
+ anchor?: string;
25
45
  /**
26
46
  * Optional lower-bound snowflake id. The server only returns messages
27
47
  * newer than this on every page, so paginating older eventually returns
@@ -4,14 +4,21 @@ exports.channelMessagesQueryKey = channelMessagesQueryKey;
4
4
  exports.useChannelMessages = useChannelMessages;
5
5
  const react_1 = require("react");
6
6
  const react_query_1 = require("@tanstack/react-query");
7
+ const snowflake_1 = require("../utils/snowflake");
8
+ const messageRangeStore_1 = require("./messageRangeStore");
7
9
  const context_1 = require("./context");
8
10
  const useChannelSubscription_1 = require("./useChannelSubscription");
9
11
  const useOfflineStatus_1 = require("./useOfflineStatus");
10
- function channelMessagesQueryKey(agentId, channelName, fields, sources) {
12
+ function channelMessagesQueryKey(agentId, channelName, fields, sources, anchor) {
11
13
  // Source-dimension the key (multiplex source subsets cache independently);
12
14
  // "*" means "all/unspecified" for a plain DooverClient/LocalAgentClient.
13
15
  const sourceDim = sources && sources.length ? [...sources].sort().join(",") : "*";
14
- const key = ["doover", "agent", agentId, "channel", channelName, "messages", "src", sourceDim];
16
+ const base = ["doover", "agent", agentId, "channel", channelName, "messages", "src", sourceDim];
17
+ // An anchored window is its own chain of cursors, so it must not share a cache
18
+ // entry with the live one — same reasoning as `fields` below. Note this is
19
+ // deliberately absent from the range store's key: sharing ranges across
20
+ // anchors is the point of it.
21
+ const key = anchor ? [...base, { anchor }] : base;
15
22
  if (!fields || fields.length === 0)
16
23
  return key;
17
24
  // A `field_name`-filtered request returns a different message stream than
@@ -22,6 +29,8 @@ function channelMessagesQueryKey(agentId, channelName, fields, sources) {
22
29
  // order doesn't fragment the cache.
23
30
  return [...key, { fields: [...fields].sort() }];
24
31
  }
32
+ /** Mirrors `MessagesApi.listMessages`'s own default. */
33
+ const DEFAULT_PAGE_LIMIT = 10;
25
34
  /**
26
35
  * Paginated infinite query over `DooverDataProvider.getMessages`, with live
27
36
  * `messageCreate` pushes prepended/appended to the newest page. The "next"
@@ -36,10 +45,27 @@ function useChannelMessages(identifier, options) {
36
45
  const liveUpdates = options?.liveUpdates ?? true;
37
46
  const fields = options?.fields;
38
47
  const initialBefore = options?.initialBefore;
48
+ const anchor = options?.anchor;
39
49
  const after = options?.after;
40
50
  const autoPaginate = options?.autoPaginate ?? false;
41
51
  const sources = options?.sources;
42
- const key = channelMessagesQueryKey(agentId, channelName, fields, sources);
52
+ const key = channelMessagesQueryKey(agentId, channelName, fields, sources, anchor);
53
+ // Keyed per stream, NOT per anchor — every window on this channel shares it.
54
+ const storeKey = JSON.stringify(channelMessagesQueryKey(agentId, channelName, fields, sources));
55
+ const store = (0, messageRangeStore_1.getChannelRangeStore)(storeKey);
56
+ // `after`/`sources` change what a page means (a short page proves a bound was
57
+ // hit, not that history ran out; multiplex merges several streams), so those
58
+ // requests bypass the range cache rather than record unsound coverage.
59
+ const rangeCacheable = after === undefined && !sources;
60
+ // A dropped socket may have swallowed messages, so the live feed stops being
61
+ // proof of coverage until a fetch re-establishes it.
62
+ (0, react_1.useEffect)(() => {
63
+ const onClose = () => store.sealTips();
64
+ client.gateway.on("close", onClose);
65
+ return () => {
66
+ client.gateway.off("close", onClose);
67
+ };
68
+ }, [client, store]);
43
69
  const onMessage = (0, react_1.useCallback)((message) => {
44
70
  // This cache entry is scoped to `fields`; the gateway delivers every
45
71
  // message on the channel, so drop live pushes whose payload doesn't
@@ -52,6 +78,8 @@ function useChannelMessages(identifier, options) {
52
78
  return;
53
79
  }
54
80
  }
81
+ if (rangeCacheable)
82
+ store.recordLive(message);
55
83
  queryClient.setQueryData(key, (current) => {
56
84
  if (!current)
57
85
  return current;
@@ -71,25 +99,76 @@ function useChannelMessages(identifier, options) {
71
99
  queryKey: key,
72
100
  enabled: !!agentId && !!channelName,
73
101
  staleTime: Infinity,
74
- initialPageParam: initialBefore,
75
- getNextPageParam: (lastPage) => lastPage && lastPage.length > 0 ? lastPage[0]?.id : undefined,
102
+ initialPageParam: { before: anchor ?? initialBefore },
103
+ // Older (down): the next cursor is the oldest id currently held.
104
+ getNextPageParam: (lastPage) => lastPage && lastPage.length > 0
105
+ ? { before: lastPage[0]?.id }
106
+ : undefined,
107
+ // Newer (up): only for an anchored window — the live window already begins
108
+ // at "now", so it has nothing above to page into. `after` = newest id held;
109
+ // the server returns the oldest messages above it, walking up toward now.
110
+ // An empty page whose own cursor was already a forward `after` means the
111
+ // top is reached; an empty *initial* page (nothing below the anchor) still
112
+ // seeds the walk from the anchor, since messages may sit above it.
113
+ getPreviousPageParam: (firstPage, _all, firstPageParam) => {
114
+ if (!anchor)
115
+ return undefined;
116
+ if (firstPage && firstPage.length > 0) {
117
+ return { after: firstPage[firstPage.length - 1]?.id };
118
+ }
119
+ if (firstPageParam?.after !== undefined)
120
+ return undefined;
121
+ return { after: anchor };
122
+ },
76
123
  queryFn: async ({ pageParam }) => {
77
124
  if (!agentId || !channelName)
78
125
  return [];
79
126
  const id = { agentId, channelName };
80
- const params = {
81
- ...(typeof pageParam === "string" ? { before: pageParam } : {}),
127
+ const param = (pageParam ?? {});
128
+ // Mirrors the API's own default; the store needs the limit actually applied
129
+ const effectiveLimit = limit ?? DEFAULT_PAGE_LIMIT;
130
+ // Pass `{ sources }` as a trailing bag only when set — cast through
131
+ // `unknown` since the TypeScript overloads don't declare it.
132
+ const runList = (params) => sources
133
+ ? client.messages.listMessages(id, params, { sources })
134
+ : client.messages.listMessages(id, params);
135
+ // Forward (newer) page: `after` alone pages oldest-first upward. Bypasses
136
+ // the range store, which models before-anchored windows only.
137
+ if (param.after !== undefined && param.before === undefined) {
138
+ const page = await runList({
139
+ after: param.after,
140
+ ...(limit !== undefined ? { limit } : {}),
141
+ ...(fields && fields.length > 0 ? { field_name: fields } : {}),
142
+ order: "asc",
143
+ });
144
+ return page;
145
+ }
146
+ // Older page. Resolve `before` here rather than letting the API default
147
+ // it, so the range store records the exact bound the request proved.
148
+ const before = typeof param.before === "string"
149
+ ? param.before
150
+ : (0, snowflake_1.generateSnowflakeIdAtTime)(new Date());
151
+ if (rangeCacheable) {
152
+ const cached = store.read(BigInt(before), effectiveLimit);
153
+ if (cached)
154
+ return cached;
155
+ }
156
+ const requestedAt = Date.now();
157
+ const page = await runList({
158
+ before,
82
159
  ...(limit !== undefined ? { limit } : {}),
83
160
  ...(fields && fields.length > 0 ? { field_name: fields } : {}),
84
161
  ...(after !== undefined ? { after } : {}),
85
162
  order: "asc",
86
- };
87
- // Pass `{ sources }` as a trailing bag only when set — cast through
88
- // `never` since the TypeScript overloads don't declare it.
89
- const sourcesArg = sources ? { sources } : undefined;
90
- const page = sourcesArg
91
- ? await client.messages.listMessages(id, params, sourcesArg)
92
- : await client.messages.listMessages(id, params);
163
+ });
164
+ if (rangeCacheable) {
165
+ store.record({
166
+ before: BigInt(before),
167
+ limit: effectiveLimit,
168
+ page: page,
169
+ at: requestedAt,
170
+ });
171
+ }
93
172
  return page;
94
173
  },
95
174
  });
@@ -229,6 +229,36 @@ function setupRest(responseFactory) {
229
229
  (0, chai_1.expect)(formData.get("json_payload")).to.equal(JSON.stringify({ data: { x: 1 } }));
230
230
  (0, chai_1.expect)(formData.get("attachment-0")).to.be.instanceOf(Blob);
231
231
  });
232
+ (0, mocha_1.it)("pages forward with a bare `after`: sends no `before`, keeps oldest-first", async () => {
233
+ const older = (0, snowflake_1.generateSnowflakeIdAtTime)(new Date("2026-01-01T00:00:00Z"));
234
+ const newer = (0, snowflake_1.generateSnowflakeIdAtTime)(new Date("2026-01-02T00:00:00Z"));
235
+ // The `after`-only route returns oldest-first natively (scan_index_forward).
236
+ const nativeAsc = [
237
+ { id: older, author_id: "u", channel: { agent_id: "a1", name: "c1" }, data: {}, attachments: [] },
238
+ { id: newer, author_id: "u", channel: { agent_id: "a1", name: "c1" }, data: {}, attachments: [] },
239
+ ];
240
+ let lastQuery;
241
+ const { rest } = setupRest((url) => {
242
+ if (url.endsWith("/messages") || url.includes("/messages?")) {
243
+ lastQuery = new URL(url).searchParams;
244
+ return (0, helpers_1.createJsonResponse)(nativeAsc);
245
+ }
246
+ return (0, helpers_1.createJsonResponse)([]);
247
+ });
248
+ const api = new messages_api_1.MessagesApi(rest);
249
+ // asc: server order already matches → no reversal, no `before` sent.
250
+ const asc = await api.listMessages("a1", "c1", { after: older, limit: 5, order: "asc" });
251
+ (0, chai_1.expect)(lastQuery?.get("after")).to.equal(older);
252
+ (0, chai_1.expect)(lastQuery?.has("before"), "bare `after` must not send `before`").to.equal(false);
253
+ (0, chai_1.expect)(asc.map((m) => m.id)).to.deep.equal([older, newer]);
254
+ // desc over the same forward route reverses the native ascending page.
255
+ const desc = await api.listMessages("a1", "c1", { after: older, limit: 5, order: "desc" });
256
+ (0, chai_1.expect)(desc.map((m) => m.id)).to.deep.equal([newer, older]);
257
+ // With `before` present, `after` is only a floor and both bounds are sent.
258
+ await api.listMessages("a1", "c1", { before: newer, after: older, limit: 5, order: "asc" });
259
+ (0, chai_1.expect)(lastQuery?.get("before")).to.equal(newer);
260
+ (0, chai_1.expect)(lastQuery?.get("after")).to.equal(older);
261
+ });
232
262
  (0, mocha_1.it)("covers aggregates and alarms methods", async () => {
233
263
  const { rest, fetchMock } = setupRest((url) => {
234
264
  if (url.includes("/attachments/")) {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,167 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const chai_1 = require("chai");
4
+ const mocha_1 = require("mocha");
5
+ const messageRangeStore_1 = require("../react/messageRangeStore");
6
+ const snowflake_1 = require("../utils/snowflake");
7
+ /** Frozen so a cursor built twice is the same cursor — real clocks make these flaky. */
8
+ const NOW = Date.now();
9
+ /** Messages at 1-minute spacing, oldest first, ending `minutesAgo` before NOW. */
10
+ function makeMessages(count, startMinutesAgo) {
11
+ return Array.from({ length: count }, (_, i) => {
12
+ const at = new Date(NOW - (startMinutesAgo - i) * 60000);
13
+ return {
14
+ id: (0, snowflake_1.generateSnowflakeIdAtTime)(at),
15
+ data: { i },
16
+ attachments: [],
17
+ author_id: "a",
18
+ channel: { agentId: "agent", channelName: "chan" },
19
+ timestamp: at.getTime(),
20
+ };
21
+ });
22
+ }
23
+ const idAt = (minutesAgo) => BigInt((0, snowflake_1.generateSnowflakeIdAtTime)(new Date(NOW - minutesAgo * 60000)));
24
+ const ids = (messages) => (messages ?? []).map((m) => m.id);
25
+ (0, mocha_1.describe)("ChannelRangeStore", () => {
26
+ let store;
27
+ (0, mocha_1.beforeEach)(() => {
28
+ store = new messageRangeStore_1.ChannelRangeStore();
29
+ });
30
+ (0, mocha_1.it)("returns undefined for a range it has never seen", () => {
31
+ (0, chai_1.expect)(store.read(idAt(0), 10)).to.equal(undefined);
32
+ });
33
+ (0, mocha_1.it)("serves a full page back from cache", () => {
34
+ const page = makeMessages(10, 20);
35
+ store.record({ before: idAt(0), limit: 10, page });
36
+ const read = store.read(idAt(0), 10);
37
+ (0, chai_1.expect)(ids(read)).to.deep.equal(ids(page));
38
+ });
39
+ (0, mocha_1.it)("returns messages ascending, matching order:asc", () => {
40
+ const page = makeMessages(5, 20);
41
+ store.record({ before: idAt(0), limit: 5, page });
42
+ const read = store.read(idAt(0), 5);
43
+ for (let i = 1; i < read.length; i++) {
44
+ (0, chai_1.expect)(BigInt(read[i].id) > BigInt(read[i - 1].id)).to.equal(true);
45
+ }
46
+ });
47
+ (0, mocha_1.it)("serves the NEWEST `limit` older than the cursor, not the first", () => {
48
+ const page = makeMessages(10, 20); // 20..11 minutes ago
49
+ store.record({ before: idAt(0), limit: 10, page });
50
+ const read = store.read(idAt(0), 3);
51
+ (0, chai_1.expect)(ids(read)).to.deep.equal(ids(page.slice(7)));
52
+ });
53
+ (0, mocha_1.it)("excludes the cursor message itself (`before` is exclusive)", () => {
54
+ const page = makeMessages(5, 20);
55
+ store.record({ before: idAt(0), limit: 5, page });
56
+ // Only 2 messages sit below page[2], so asking for more would (rightly) be
57
+ // unanswerable — nothing proves what lies below the segment's `lo`.
58
+ const cursor = BigInt(page[2].id);
59
+ const read = store.read(cursor, 2);
60
+ (0, chai_1.expect)(read.every((m) => BigInt(m.id) < cursor)).to.equal(true);
61
+ (0, chai_1.expect)(ids(read)).to.deep.equal(ids(page.slice(0, 2)));
62
+ });
63
+ (0, mocha_1.it)("will not answer beyond what it has proven", () => {
64
+ const page = makeMessages(10, 20);
65
+ store.record({ before: idAt(0), limit: 10, page });
66
+ // Holds 10, asked for 20, and nothing says the range bottoms out here
67
+ (0, chai_1.expect)(store.read(idAt(0), 20)).to.equal(undefined);
68
+ });
69
+ (0, mocha_1.it)("answers short when a short page proved the range bottoms out", () => {
70
+ const page = makeMessages(3, 20);
71
+ store.record({ before: idAt(0), limit: 10, page }); // 3 < 10 => atStart
72
+ const read = store.read(idAt(0), 10);
73
+ (0, chai_1.expect)(ids(read)).to.deep.equal(ids(page));
74
+ });
75
+ (0, mocha_1.it)("will not answer a cursor above its proven upper bound", () => {
76
+ const page = makeMessages(10, 20);
77
+ store.record({ before: idAt(10), limit: 10, page });
78
+ // Nothing proves what sits between 10 minutes ago and now
79
+ (0, chai_1.expect)(store.read(idAt(0), 5)).to.equal(undefined);
80
+ });
81
+ (0, mocha_1.it)("merges touching ranges so a later read spans both", () => {
82
+ const older = makeMessages(10, 40); // 40..31
83
+ const newer = makeMessages(10, 20); // 20..11
84
+ store.record({ before: idAt(0), limit: 10, page: newer });
85
+ // Paging older: cursor is the oldest we held
86
+ store.record({ before: BigInt(newer[0].id), limit: 10, page: older, at: NOW });
87
+ (0, chai_1.expect)(store.snapshot()).to.have.length(1);
88
+ const read = store.read(idAt(0), 20);
89
+ (0, chai_1.expect)(ids(read)).to.deep.equal([...ids(older), ...ids(newer)]);
90
+ });
91
+ (0, mocha_1.it)("merges overlapping ranges without duplicating", () => {
92
+ const page = makeMessages(10, 20);
93
+ store.record({ before: idAt(0), limit: 10, page });
94
+ store.record({ before: idAt(0), limit: 10, page }); // same window again
95
+ (0, chai_1.expect)(store.snapshot()).to.have.length(1);
96
+ (0, chai_1.expect)(ids(store.read(idAt(0), 10))).to.deep.equal(ids(page));
97
+ });
98
+ (0, mocha_1.it)("keeps disjoint ranges apart rather than claiming the gap", () => {
99
+ const ancient = makeMessages(5, 500);
100
+ const recent = makeMessages(5, 20);
101
+ store.record({ before: idAt(0), limit: 5, page: recent });
102
+ store.record({ before: idAt(400), limit: 5, page: ancient });
103
+ (0, chai_1.expect)(store.snapshot()).to.have.length(2);
104
+ // The hole between them is not covered, so a span across it must refetch
105
+ (0, chai_1.expect)(store.read(idAt(0), 10)).to.equal(undefined);
106
+ });
107
+ (0, mocha_1.it)("serves an anchored read from a range fetched for a different anchor", () => {
108
+ const page = makeMessages(30, 60);
109
+ store.record({ before: idAt(0), limit: 30, page });
110
+ // Jumping to 30 minutes ago costs nothing: already proven
111
+ const read = store.read(idAt(30), 5);
112
+ (0, chai_1.expect)(read).to.not.equal(undefined);
113
+ (0, chai_1.expect)(read.every((m) => m.timestamp < Date.now() - 30 * 60000)).to.equal(true);
114
+ });
115
+ (0, mocha_1.it)("orders by numeric id, not string (500 sorts before 4000)", () => {
116
+ const short = { id: "500", timestamp: 1 };
117
+ const long = { id: "4000", timestamp: 2 };
118
+ store.record({ before: 5000n, limit: 10, page: [long, short], at: NOW });
119
+ (0, chai_1.expect)(ids(store.read(5000n, 10))).to.deep.equal(["500", "4000"]);
120
+ });
121
+ (0, mocha_1.it)("records an empty page as a proven-empty range", () => {
122
+ store.record({ before: idAt(0), limit: 10, page: [] });
123
+ (0, chai_1.expect)(store.read(idAt(0), 10)).to.deep.equal([]);
124
+ });
125
+ (0, mocha_1.describe)("live tip", () => {
126
+ (0, mocha_1.it)("extends coverage with pushes when proven up to now", () => {
127
+ const page = makeMessages(5, 20);
128
+ store.record({ before: idAt(0), limit: 5, page });
129
+ const [live] = makeMessages(1, -1); // one minute into the future
130
+ store.recordLive(live);
131
+ const read = store.read(BigInt(live.id) + 1n, 6);
132
+ (0, chai_1.expect)(ids(read)).to.deep.equal([...ids(page), live.id]);
133
+ });
134
+ (0, mocha_1.it)("ignores pushes for a stale window, which may not be contiguous", () => {
135
+ const page = makeMessages(5, 500);
136
+ // Proven only up to ~8 hours ago, so a push now leaves a hole
137
+ store.record({ before: idAt(480), limit: 5, page });
138
+ const [live] = makeMessages(1, 0);
139
+ store.recordLive(live);
140
+ (0, chai_1.expect)(store.read(BigInt(live.id) + 1n, 6)).to.equal(undefined);
141
+ });
142
+ (0, mocha_1.it)("stops extending once the socket drops", () => {
143
+ const page = makeMessages(5, 20);
144
+ store.record({ before: idAt(0), limit: 5, page });
145
+ store.sealTips(); // gateway "close"
146
+ const [live] = makeMessages(1, -1);
147
+ store.recordLive(live);
148
+ // The push is not trusted to prove the range, so the gap must be refetched
149
+ (0, chai_1.expect)(store.read(BigInt(live.id) + 1n, 6)).to.equal(undefined);
150
+ });
151
+ (0, mocha_1.it)("does not duplicate a push already fetched", () => {
152
+ const page = makeMessages(5, 20);
153
+ store.record({ before: idAt(0), limit: 5, page });
154
+ store.recordLive(page[4]);
155
+ (0, chai_1.expect)(ids(store.read(idAt(0), 5))).to.deep.equal(ids(page));
156
+ });
157
+ });
158
+ });
159
+ (0, mocha_1.describe)("getChannelRangeStore", () => {
160
+ (0, mocha_1.beforeEach)(() => (0, messageRangeStore_1.resetChannelRangeStores)());
161
+ (0, mocha_1.it)("shares one store per stream key", () => {
162
+ (0, chai_1.expect)((0, messageRangeStore_1.getChannelRangeStore)("a|c")).to.equal((0, messageRangeStore_1.getChannelRangeStore)("a|c"));
163
+ });
164
+ (0, mocha_1.it)("keeps different streams apart", () => {
165
+ (0, chai_1.expect)((0, messageRangeStore_1.getChannelRangeStore)("a|c")).to.not.equal((0, messageRangeStore_1.getChannelRangeStore)("a|d"));
166
+ });
167
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,139 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const jsx_runtime_1 = require("react/jsx-runtime");
7
+ const chai_1 = require("chai");
8
+ const mocha_1 = require("mocha");
9
+ const react_1 = require("@testing-library/react");
10
+ const react_query_1 = require("@tanstack/react-query");
11
+ const sinon_1 = __importDefault(require("sinon"));
12
+ const doover_client_1 = require("../client/doover-client");
13
+ const react_2 = require("../react");
14
+ const messageRangeStore_1 = require("../react/messageRangeStore");
15
+ const snowflake_1 = require("../utils/snowflake");
16
+ const helpers_1 = require("./helpers");
17
+ const AGENT = "agent-1";
18
+ const CHANNEL = "camera_1";
19
+ const NOW = Date.now();
20
+ /** `COUNT` messages, one per minute, ascending by id (index 0 = oldest). */
21
+ const COUNT = 60;
22
+ const ALL = Array.from({ length: COUNT }, (_, i) => {
23
+ const at = new Date(NOW - (COUNT - 1 - i) * 60000);
24
+ return {
25
+ id: (0, snowflake_1.generateSnowflakeIdAtTime)(at),
26
+ data: { i },
27
+ attachments: [],
28
+ author_id: "author",
29
+ channel: { agent_id: AGENT, name: CHANNEL },
30
+ };
31
+ }).sort((a, b) => (BigInt(a.id) < BigInt(b.id) ? -1 : 1));
32
+ /**
33
+ * Emulates the REST route's *native* ordering, which the SDK client then
34
+ * reverses per `order`: `before` returns the newest N below the cursor,
35
+ * newest-first; a bare `after` returns the oldest N above it, oldest-first.
36
+ */
37
+ function serveNative(query) {
38
+ const before = query.get("before");
39
+ const after = query.get("after");
40
+ const limit = Number(query.get("limit") ?? "10");
41
+ if (before && !after) {
42
+ const below = ALL.filter((m) => BigInt(m.id) < BigInt(before));
43
+ return below.slice(Math.max(0, below.length - limit)).reverse(); // newest-first
44
+ }
45
+ if (after && !before) {
46
+ const above = ALL.filter((m) => BigInt(m.id) > BigInt(after));
47
+ return above.slice(0, limit); // oldest-first
48
+ }
49
+ // both bounds: newest-first within (after, before]
50
+ const within = ALL.filter((m) => BigInt(m.id) < BigInt(before) && BigInt(m.id) > BigInt(after));
51
+ return within.slice(Math.max(0, within.length - limit)).reverse();
52
+ }
53
+ function makeClient() {
54
+ const calls = [];
55
+ const fetchMock = sinon_1.default.stub().callsFake(async (input) => {
56
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
57
+ if (url.includes("/messages")) {
58
+ const q = new URL(url).searchParams;
59
+ calls.push(q);
60
+ return (0, helpers_1.createJsonResponse)(serveNative(q));
61
+ }
62
+ return (0, helpers_1.createJsonResponse)({});
63
+ });
64
+ const client = new doover_client_1.DooverClient({
65
+ dataRestUrl: "https://api.example.com",
66
+ controlApiUrl: "https://control.example.com",
67
+ dataWssUrl: "wss://ws.example.com",
68
+ fetchImpl: fetchMock,
69
+ webSocketImpl: helpers_1.MockWebSocket,
70
+ disableBrowserLifecycleHooks: true,
71
+ });
72
+ return { client, calls };
73
+ }
74
+ function wrapper(client, queryClient) {
75
+ return function Wrapper({ children }) {
76
+ return ((0, jsx_runtime_1.jsx)(react_query_1.QueryClientProvider, { client: queryClient, children: (0, jsx_runtime_1.jsx)(react_2.DooverProvider, { client: client, children: children }) }));
77
+ };
78
+ }
79
+ const newQueryClient = () => new react_query_1.QueryClient({ defaultOptions: { queries: { retry: false } } });
80
+ const idAtIndex = (i) => ALL[i].id;
81
+ const timeOfIndex = (i) => NOW - (COUNT - 1 - i) * 60000;
82
+ /** A snowflake landing strictly between messages i and i+1 (30s after i). */
83
+ const anchorAfter = (i) => (0, snowflake_1.generateSnowflakeIdAtTime)(new Date(timeOfIndex(i) + 30000));
84
+ (0, mocha_1.describe)("useChannelMessages forward pagination", () => {
85
+ (0, mocha_1.beforeEach)(() => {
86
+ (0, helpers_1.installSessionStorageMock)();
87
+ (0, messageRangeStore_1.resetChannelRangeStores)();
88
+ });
89
+ (0, mocha_1.afterEach)(() => {
90
+ sinon_1.default.restore();
91
+ });
92
+ (0, mocha_1.it)("pages forward from an anchor into newer messages", async () => {
93
+ const { client } = makeClient();
94
+ const qc = newQueryClient();
95
+ // Anchor between index 29 and 30 — half the history is newer than it.
96
+ const anchor = anchorAfter(29);
97
+ const { result } = (0, react_1.renderHook)(() => (0, react_2.useChannelMessages)({ agentId: AGENT, channelName: CHANNEL }, { limit: 10, anchor }), { wrapper: wrapper(client, qc) });
98
+ await (0, react_1.waitFor)(() => (0, chai_1.expect)(result.current.messages).to.have.length(10));
99
+ // The anchored page is the 10 newest *below* the anchor: indices 20..29.
100
+ (0, chai_1.expect)(result.current.messages.every((m) => BigInt(m.id) < BigInt(anchor))).to.equal(true);
101
+ (0, chai_1.expect)(result.current.hasPreviousPage).to.equal(true);
102
+ await (0, react_1.act)(async () => {
103
+ await result.current.fetchPreviousPage();
104
+ });
105
+ await (0, react_1.waitFor)(() => (0, chai_1.expect)(result.current.messages.some((m) => BigInt(m.id) > BigInt(anchor))).to.equal(true));
106
+ // Forward page brought in the messages immediately above the anchor (index 30+).
107
+ (0, chai_1.expect)(result.current.messages.map((m) => m.data).some((d) => d.i >= 30)).to.equal(true);
108
+ });
109
+ (0, mocha_1.it)("exposes no previous page for an unanchored (live) window", async () => {
110
+ const { client } = makeClient();
111
+ const qc = newQueryClient();
112
+ const { result } = (0, react_1.renderHook)(() => (0, react_2.useChannelMessages)({ agentId: AGENT, channelName: CHANNEL }, { limit: 10 }), { wrapper: wrapper(client, qc) });
113
+ await (0, react_1.waitFor)(() => (0, chai_1.expect)(result.current.messages).to.have.length(10));
114
+ (0, chai_1.expect)(result.current.hasPreviousPage).to.equal(false);
115
+ });
116
+ (0, mocha_1.it)("recovers messages above an anchor that has nothing below it, and terminates", async () => {
117
+ const { client } = makeClient();
118
+ const qc = newQueryClient();
119
+ // Anchor older than every message: the initial before=anchor page is empty.
120
+ const anchor = (0, snowflake_1.generateSnowflakeIdAtTime)(new Date(NOW - COUNT * 10 * 60000));
121
+ const { result } = (0, react_1.renderHook)(() => (0, react_2.useChannelMessages)({ agentId: AGENT, channelName: CHANNEL }, { limit: 10, anchor }), { wrapper: wrapper(client, qc) });
122
+ await (0, react_1.waitFor)(() => (0, chai_1.expect)(result.current.isLoading).to.equal(false));
123
+ (0, chai_1.expect)(result.current.messages).to.have.length(0);
124
+ // Empty below, but the walk still seeds from the anchor itself.
125
+ (0, chai_1.expect)(result.current.hasPreviousPage).to.equal(true);
126
+ // Drain forward until exhausted — must terminate, never loop.
127
+ for (let guard = 0; guard < 20 && result.current.hasPreviousPage; guard++) {
128
+ await (0, react_1.act)(async () => {
129
+ await result.current.fetchPreviousPage();
130
+ });
131
+ }
132
+ (0, chai_1.expect)(result.current.hasPreviousPage).to.equal(false);
133
+ // Every message above the anchor is recovered (order is the consumer's job).
134
+ const ids = result.current.messages.map((m) => m.id);
135
+ (0, chai_1.expect)(ids).to.have.length(COUNT);
136
+ (0, chai_1.expect)(ids).to.include(idAtIndex(0));
137
+ (0, chai_1.expect)(ids).to.include(idAtIndex(COUNT - 1));
138
+ });
139
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const jsx_runtime_1 = require("react/jsx-runtime");
7
+ const chai_1 = require("chai");
8
+ const mocha_1 = require("mocha");
9
+ const react_1 = require("@testing-library/react");
10
+ const react_query_1 = require("@tanstack/react-query");
11
+ const sinon_1 = __importDefault(require("sinon"));
12
+ const doover_client_1 = require("../client/doover-client");
13
+ const react_2 = require("../react");
14
+ const messageRangeStore_1 = require("../react/messageRangeStore");
15
+ const snowflake_1 = require("../utils/snowflake");
16
+ const helpers_1 = require("./helpers");
17
+ const AGENT = "agent-1";
18
+ const CHANNEL = "camera_1";
19
+ const NOW = Date.now();
20
+ /** Newest-first, as the bare REST route returns it. */
21
+ function messagesDesc(count, startMinutesAgo) {
22
+ return Array.from({ length: count }, (_, i) => {
23
+ const at = new Date(NOW - (startMinutesAgo + i) * 60000);
24
+ return {
25
+ id: (0, snowflake_1.generateSnowflakeIdAtTime)(at),
26
+ data: { i },
27
+ attachments: [],
28
+ author_id: "author",
29
+ channel: { agent_id: AGENT, name: CHANNEL },
30
+ };
31
+ });
32
+ }
33
+ function makeClient(page) {
34
+ const calls = [];
35
+ const fetchMock = sinon_1.default.stub().callsFake(async (input) => {
36
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
37
+ if (url.includes("/messages")) {
38
+ const parsed = new URL(url);
39
+ calls.push({
40
+ before: parsed.searchParams.get("before"),
41
+ limit: parsed.searchParams.get("limit"),
42
+ });
43
+ return (0, helpers_1.createJsonResponse)(page);
44
+ }
45
+ return (0, helpers_1.createJsonResponse)({});
46
+ });
47
+ const client = new doover_client_1.DooverClient({
48
+ dataRestUrl: "https://api.example.com",
49
+ controlApiUrl: "https://control.example.com",
50
+ dataWssUrl: "wss://ws.example.com",
51
+ fetchImpl: fetchMock,
52
+ webSocketImpl: helpers_1.MockWebSocket,
53
+ disableBrowserLifecycleHooks: true,
54
+ });
55
+ return { client, calls };
56
+ }
57
+ /** One QueryClient per test, shared by both hooks, as a real app would have. */
58
+ function wrapper(client, queryClient) {
59
+ return function Wrapper({ children }) {
60
+ return ((0, jsx_runtime_1.jsx)(react_query_1.QueryClientProvider, { client: queryClient, children: (0, jsx_runtime_1.jsx)(react_2.DooverProvider, { client: client, children: children }) }));
61
+ };
62
+ }
63
+ const newQueryClient = () => new react_query_1.QueryClient({ defaultOptions: { queries: { retry: false } } });
64
+ (0, mocha_1.describe)("useChannelMessages range cache", () => {
65
+ (0, mocha_1.beforeEach)(() => {
66
+ (0, helpers_1.installSessionStorageMock)();
67
+ (0, messageRangeStore_1.resetChannelRangeStores)();
68
+ });
69
+ (0, mocha_1.afterEach)(() => {
70
+ sinon_1.default.restore();
71
+ });
72
+ (0, mocha_1.it)("serves an anchored window from history already fetched, without refetching", async () => {
73
+ const page = messagesDesc(30, 1); // 1..30 minutes ago
74
+ const { client, calls } = makeClient(page);
75
+ const qc = newQueryClient();
76
+ const live = (0, react_1.renderHook)(() => (0, react_2.useChannelMessages)({ agentId: AGENT, channelName: CHANNEL }, { limit: 30 }), { wrapper: wrapper(client, qc) });
77
+ await (0, react_1.waitFor)(() => (0, chai_1.expect)(live.result.current.messages).to.have.length(30));
78
+ (0, chai_1.expect)(calls).to.have.length(1);
79
+ // Anchor inside the range just proven — a different chain, same store
80
+ const anchor = (0, snowflake_1.generateSnowflakeIdAtTime)(new Date(NOW - 10 * 60000));
81
+ const anchored = (0, react_1.renderHook)(() => (0, react_2.useChannelMessages)({ agentId: AGENT, channelName: CHANNEL }, { limit: 5, anchor }), { wrapper: wrapper(client, qc) });
82
+ await (0, react_1.waitFor)(() => (0, chai_1.expect)(anchored.result.current.messages).to.have.length(5));
83
+ (0, chai_1.expect)(calls, "anchoring over covered history should not hit the network").to.have.length(1);
84
+ (0, chai_1.expect)(anchored.result.current.messages.every((m) => BigInt(m.id) < BigInt(anchor))).to.equal(true);
85
+ });
86
+ (0, mocha_1.it)("fetches when the anchor lands outside anything proven", async () => {
87
+ const page = messagesDesc(30, 1);
88
+ const { client, calls } = makeClient(page);
89
+ const qc = newQueryClient();
90
+ const live = (0, react_1.renderHook)(() => (0, react_2.useChannelMessages)({ agentId: AGENT, channelName: CHANNEL }, { limit: 30 }), { wrapper: wrapper(client, qc) });
91
+ await (0, react_1.waitFor)(() => (0, chai_1.expect)(live.result.current.messages).to.have.length(30));
92
+ // A year back is nowhere near the covered range
93
+ const anchor = (0, snowflake_1.generateSnowflakeIdAtTime)(new Date(NOW - 365 * 24 * 60 * 60000));
94
+ (0, react_1.renderHook)(() => (0, react_2.useChannelMessages)({ agentId: AGENT, channelName: CHANNEL }, { limit: 5, anchor }), { wrapper: wrapper(client, qc) });
95
+ await (0, react_1.waitFor)(() => (0, chai_1.expect)(calls).to.have.length(2));
96
+ (0, chai_1.expect)(calls[1].before).to.equal(anchor);
97
+ });
98
+ (0, mocha_1.it)("keeps the unanchored key untouched, so existing consumers are unaffected", async () => {
99
+ const page = messagesDesc(10, 1);
100
+ const { client, calls } = makeClient(page);
101
+ const qc = newQueryClient();
102
+ const first = (0, react_1.renderHook)(() => (0, react_2.useChannelMessages)({ agentId: AGENT, channelName: CHANNEL }, { limit: 10 }), { wrapper: wrapper(client, qc) });
103
+ await (0, react_1.waitFor)(() => (0, chai_1.expect)(first.result.current.messages).to.have.length(10));
104
+ // Same options => same query key => React Query serves it, no second request
105
+ const second = (0, react_1.renderHook)(() => (0, react_2.useChannelMessages)({ agentId: AGENT, channelName: CHANNEL }, { limit: 10 }), { wrapper: wrapper(client, qc) });
106
+ await (0, react_1.waitFor)(() => (0, chai_1.expect)(second.result.current.messages).to.have.length(10));
107
+ (0, chai_1.expect)(calls).to.have.length(1);
108
+ });
109
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const jsx_runtime_1 = require("react/jsx-runtime");
7
+ const chai_1 = require("chai");
8
+ const mocha_1 = require("mocha");
9
+ const react_1 = require("@testing-library/react");
10
+ const react_query_1 = require("@tanstack/react-query");
11
+ const doover_client_1 = require("../client/doover-client");
12
+ const react_2 = require("../react");
13
+ const helpers_1 = require("./helpers");
14
+ const sinon_1 = __importDefault(require("sinon"));
15
+ /**
16
+ * Fetch mock for the multi-agent messages endpoint. `pagesByRound` supplies
17
+ * one canned response per call, in order, so a test can hand back
18
+ * `next_cursors` on early rounds and drain on the last one.
19
+ */
20
+ function makeClient(pagesByRound) {
21
+ let round = 0;
22
+ const calls = [];
23
+ const fetchMock = sinon_1.default.stub().callsFake(async (input) => {
24
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
25
+ if (url.includes("/messages")) {
26
+ const parsed = new URL(url);
27
+ calls.push({
28
+ agent_id: parsed.searchParams.getAll("agent_id"),
29
+ agent_before: parsed.searchParams.getAll("agent_before"),
30
+ });
31
+ const page = pagesByRound[Math.min(round, pagesByRound.length - 1)];
32
+ round += 1;
33
+ return (0, helpers_1.createJsonResponse)(page);
34
+ }
35
+ return (0, helpers_1.createJsonResponse)({});
36
+ });
37
+ const client = new doover_client_1.DooverClient({
38
+ dataRestUrl: "https://api.example.com",
39
+ controlApiUrl: "https://control.example.com",
40
+ dataWssUrl: "wss://ws.example.com",
41
+ fetchImpl: fetchMock,
42
+ webSocketImpl: helpers_1.MockWebSocket,
43
+ disableBrowserLifecycleHooks: true,
44
+ });
45
+ return { client, calls };
46
+ }
47
+ function wrapper(client) {
48
+ const queryClient = new react_query_1.QueryClient({ defaultOptions: { queries: { retry: false } } });
49
+ return function Wrapper({ children }) {
50
+ return ((0, jsx_runtime_1.jsx)(react_query_1.QueryClientProvider, { client: queryClient, children: (0, jsx_runtime_1.jsx)(react_2.DooverProvider, { client: client, children: children }) }));
51
+ };
52
+ }
53
+ function msg(id, agentId) {
54
+ return { id, channel: { agent_id: agentId, name: "tag_values" }, data: {} };
55
+ }
56
+ (0, mocha_1.describe)("useMultiAgentChannelMessages autoPaginate", () => {
57
+ (0, mocha_1.beforeEach)(() => {
58
+ (0, helpers_1.installSessionStorageMock)();
59
+ helpers_1.MockWebSocket.reset();
60
+ });
61
+ (0, mocha_1.afterEach)(() => {
62
+ sinon_1.default.restore();
63
+ });
64
+ (0, mocha_1.it)("walks back per-agent via next_cursors until the window drains", async () => {
65
+ const { client, calls } = makeClient([
66
+ // round 1: a1 hit its limit (cursor returned), a2 drained already
67
+ { results: [msg("100", "a1"), msg("99", "a2")], next_cursors: { a1: "90" } },
68
+ // round 2: a1 drains
69
+ { results: [msg("89", "a1")], next_cursors: {} },
70
+ ]);
71
+ const { result } = (0, react_1.renderHook)(() => (0, react_2.useMultiAgentChannelMessages)("tag_values", ["a1", "a2"], {
72
+ after: "1",
73
+ agentMessageLimit: 2,
74
+ autoPaginate: true,
75
+ liveUpdates: false,
76
+ }), { wrapper: wrapper(client) });
77
+ await (0, react_1.waitFor)(() => (0, chai_1.expect)(result.current.messages).to.have.length(3));
78
+ (0, chai_1.expect)(result.current.hasNextPage).to.equal(false);
79
+ // Second request must resume ONLY a1, using its own cursor as agent_before.
80
+ (0, chai_1.expect)(calls).to.have.length(2);
81
+ (0, chai_1.expect)(calls[1].agent_id).to.deep.equal(["a1"]);
82
+ (0, chai_1.expect)(calls[1].agent_before).to.deep.equal(["90"]);
83
+ });
84
+ (0, mocha_1.it)("does not paginate when autoPaginate is off", async () => {
85
+ const { client, calls } = makeClient([
86
+ { results: [msg("100", "a1")], next_cursors: { a1: "90" } },
87
+ ]);
88
+ const { result } = (0, react_1.renderHook)(() => (0, react_2.useMultiAgentChannelMessages)("tag_values", ["a1"], {
89
+ agentMessageLimit: 1,
90
+ liveUpdates: false,
91
+ }), { wrapper: wrapper(client) });
92
+ await (0, react_1.waitFor)(() => (0, chai_1.expect)(result.current.messages).to.have.length(1));
93
+ // hasNextPage is exposed, but nothing auto-fetches the second page.
94
+ (0, chai_1.expect)(result.current.hasNextPage).to.equal(true);
95
+ (0, chai_1.expect)(calls).to.have.length(1);
96
+ });
97
+ (0, mocha_1.it)("dedupes messages that repeat across pages", async () => {
98
+ const { client } = makeClient([
99
+ { results: [msg("100", "a1")], next_cursors: { a1: "100" } },
100
+ // overlaps the previous page's id 100 (legacy-style re-return)
101
+ { results: [msg("100", "a1"), msg("90", "a1")], next_cursors: {} },
102
+ ]);
103
+ const { result } = (0, react_1.renderHook)(() => (0, react_2.useMultiAgentChannelMessages)("tag_values", ["a1"], {
104
+ after: "1",
105
+ agentMessageLimit: 1,
106
+ autoPaginate: true,
107
+ liveUpdates: false,
108
+ }), { wrapper: wrapper(client) });
109
+ await (0, react_1.waitFor)(() => (0, chai_1.expect)(result.current.messages).to.have.length(2));
110
+ (0, chai_1.expect)(result.current.hasNextPage).to.equal(false);
111
+ (0, chai_1.expect)(result.current.messages.map((m) => m.id)).to.deep.equal(["100", "90"]);
112
+ });
113
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doover-js",
3
- "version": "0.6.9",
3
+ "version": "0.7.1",
4
4
  "description": "TypeScript client for Doover.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",