doover-js 0.9.0 → 0.10.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.
@@ -65,6 +65,12 @@ export declare class ChannelRangeStore {
65
65
  * between them, and claiming coverage across it would hide messages forever.
66
66
  */
67
67
  recordLive(message: MessageStructure): void;
68
+ /**
69
+ * Replace a message already held by one or more proven ranges. MessageUpdate
70
+ * carries the complete server representation, so both data and attachments
71
+ * supersede the cached create-time copy without changing range coverage.
72
+ */
73
+ recordUpdate(message: MessageStructure): void;
68
74
  /**
69
75
  * Stop trusting the live feed to extend coverage — call when the socket drops.
70
76
  * Messages sent while disconnected would leave a hole, so segments go back to
@@ -69,6 +69,18 @@ class ChannelRangeStore {
69
69
  if (id >= tip.hi)
70
70
  tip.hi = id + 1n;
71
71
  }
72
+ /**
73
+ * Replace a message already held by one or more proven ranges. MessageUpdate
74
+ * carries the complete server representation, so both data and attachments
75
+ * supersede the cached create-time copy without changing range coverage.
76
+ */
77
+ recordUpdate(message) {
78
+ for (const segment of this.segments) {
79
+ const index = segment.messages.findIndex((item) => item.id === message.id);
80
+ if (index >= 0)
81
+ segment.messages[index] = message;
82
+ }
83
+ }
72
84
  /**
73
85
  * Stop trusting the live feed to extend coverage — call when the socket drops.
74
86
  * Messages sent while disconnected would leave a hole, so segments go back to
@@ -75,8 +75,9 @@ export interface UseChannelMessagesResult<TData> extends Omit<UseInfiniteQueryRe
75
75
  }
76
76
  /**
77
77
  * Paginated infinite query over `DooverDataProvider.getMessages`, with live
78
- * `messageCreate` pushes prepended/appended to the newest page. The "next"
79
- * page fetches older messages (cursor = oldest-loaded message id).
78
+ * `messageCreate` pushes are appended to the newest page and `messageUpdate`
79
+ * pushes replace matching cached messages. The "next" page fetches older
80
+ * messages (cursor = oldest-loaded message id).
80
81
  */
81
82
  export declare function useChannelMessages<TData = unknown>(identifier: ChannelIdentifier, options?: UseChannelMessagesOptions): UseChannelMessagesResult<TData>;
82
83
  export {};
@@ -33,8 +33,9 @@ function channelMessagesQueryKey(agentId, channelName, fields, sources, anchor)
33
33
  const DEFAULT_PAGE_LIMIT = 10;
34
34
  /**
35
35
  * Paginated infinite query over `DooverDataProvider.getMessages`, with live
36
- * `messageCreate` pushes prepended/appended to the newest page. The "next"
37
- * page fetches older messages (cursor = oldest-loaded message id).
36
+ * `messageCreate` pushes are appended to the newest page and `messageUpdate`
37
+ * pushes replace matching cached messages. The "next" page fetches older
38
+ * messages (cursor = oldest-loaded message id).
38
39
  */
39
40
  function useChannelMessages(identifier, options) {
40
41
  const client = (0, context_1.useDooverClient)();
@@ -94,7 +95,40 @@ function useChannelMessages(identifier, options) {
94
95
  },
95
96
  // eslint-disable-next-line react-hooks/exhaustive-deps
96
97
  [queryClient, agentId, channelName, fields?.join(","), sources?.join(",")]);
97
- (0, useChannelSubscription_1.useChannelSubscription)(liveUpdates ? identifier : undefined, { onMessage });
98
+ const onMessageUpdate = (0, react_1.useCallback)((message) => {
99
+ // A filtered stream must not accept an update that does not belong to it.
100
+ // Newly qualifying messages are left for a refetch because inserting one
101
+ // into an arbitrary cursor page could invalidate that page's boundaries.
102
+ if (fields && fields.length > 0) {
103
+ const data = message.data;
104
+ if (!data ||
105
+ typeof data !== "object" ||
106
+ !fields.some((field) => field in data)) {
107
+ return;
108
+ }
109
+ }
110
+ if (rangeCacheable)
111
+ store.recordUpdate(message);
112
+ queryClient.setQueryData(key, (current) => {
113
+ if (!current)
114
+ return current;
115
+ let changed = false;
116
+ const typed = message;
117
+ const pages = current.pages.map((page) => page.map((item) => {
118
+ if (item.id !== typed.id)
119
+ return item;
120
+ changed = true;
121
+ return typed;
122
+ }));
123
+ return changed ? { ...current, pages } : current;
124
+ });
125
+ },
126
+ // eslint-disable-next-line react-hooks/exhaustive-deps
127
+ [queryClient, agentId, channelName, fields?.join(","), sources?.join(",")]);
128
+ (0, useChannelSubscription_1.useChannelSubscription)(liveUpdates ? identifier : undefined, {
129
+ onMessage,
130
+ onMessageUpdate,
131
+ });
98
132
  const query = (0, react_query_1.useInfiniteQuery)({
99
133
  queryKey: key,
100
134
  enabled: !!agentId && !!channelName,
@@ -32,6 +32,13 @@ export interface UseSendRpcOptions {
32
32
  * `["doover", "agent", agentId, "channel", channelName, "rpc", method]`.
33
33
  */
34
34
  mutationKey?: readonly unknown[];
35
+ /** How long to wait to hear from the device at all. See `SendRpcOptions`. */
36
+ timeoutMs?: number;
37
+ /**
38
+ * How long the command may then stay in flight once the device has answered,
39
+ * re-armed on every progress report. See `SendRpcOptions`.
40
+ */
41
+ pendingTimeoutMs?: number;
35
42
  }
36
43
  export interface UseSendRpcResult<TRequest, TResponse, TPending> extends Omit<UseMutationResult<TResponse, unknown, SendRpcVariables<TRequest>, void>, "isPending" | "mutate" | "mutateAsync"> {
37
44
  /** Most recent status across all commands (by `submittedAt`). */
@@ -120,6 +120,12 @@ function useSendRpc(identifier, options) {
120
120
  }
121
121
  return client.rpc.send({ agentId: identifier.agentId, channelName: identifier.channelName }, rpcRequest, {
122
122
  onStatus: (status) => appendStatus(commandId, status),
123
+ ...(options.timeoutMs !== undefined
124
+ ? { timeoutMs: options.timeoutMs }
125
+ : {}),
126
+ ...(options.pendingTimeoutMs !== undefined
127
+ ? { pendingTimeoutMs: options.pendingTimeoutMs }
128
+ : {}),
123
129
  });
124
130
  },
125
131
  onSuccess: (data, variables) => settleCommand(variables.commandId, {
@@ -5,7 +5,20 @@ import type { RpcRequest, RpcStatus } from "../types/common";
5
5
  export interface SendRpcOptions<TPending = undefined> {
6
6
  onStatus?: (status: RpcStatus<TPending>) => void;
7
7
  signal?: AbortSignal;
8
+ /**
9
+ * How long to wait to hear from the device at all. A healthy device answers
10
+ * within seconds however long the work then takes, so keep this short and
11
+ * use `pendingTimeoutMs` for the work itself.
12
+ */
8
13
  timeoutMs?: number;
14
+ /**
15
+ * How long the command may then stay in flight once the device has answered
16
+ * it — acknowledged it, or reported progress. Re-armed on every subsequent
17
+ * status update, so a device that keeps reporting keeps its command alive,
18
+ * while one that answers and then dies still fails. Without this, a handler
19
+ * that legitimately runs longer than `timeoutMs` is killed mid-flight.
20
+ */
21
+ pendingTimeoutMs?: number;
9
22
  }
10
23
  interface ChannelIdentifierLike {
11
24
  agentId: string;
@@ -2,6 +2,10 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.RpcDispatcher = void 0;
4
4
  const errors_1 = require("./errors");
5
+ /** Whether the device has answered this command, rather than only received it. */
6
+ function hasDeviceAnswered(status) {
7
+ return status.code === "acknowledged" || status.code === "pending";
8
+ }
5
9
  function isRpcMessageData(data) {
6
10
  return (typeof data === "object" &&
7
11
  data !== null &&
@@ -45,6 +49,7 @@ class RpcDispatcher {
45
49
  channelKey,
46
50
  channel: channelRef,
47
51
  startedAt,
52
+ pendingTimeoutMs: options?.pendingTimeoutMs,
48
53
  };
49
54
  if (options?.signal) {
50
55
  const onAbort = () => this.settle(message.id, "abort", undefined, options.signal?.reason);
@@ -109,6 +114,15 @@ class RpcDispatcher {
109
114
  else if (status.code === "error") {
110
115
  this.settle(msg.id, "error", undefined, new errors_1.DooverRpcError(status, pending.request));
111
116
  }
117
+ else if (hasDeviceAnswered(status) && pending.pendingTimeoutMs !== undefined) {
118
+ // The device is alive and working: swap the "no response" deadline for
119
+ // the pending budget, and re-arm it on every report that follows.
120
+ if (pending.timer)
121
+ clearTimeout(pending.timer);
122
+ pending.timer = setTimeout(() => {
123
+ this.settle(msg.id, "timeout", undefined, new Error("Device stopped reporting progress"));
124
+ }, pending.pendingTimeoutMs);
125
+ }
112
126
  }
113
127
  settle(messageId, outcome, response, rejection) {
114
128
  const pending = this.pending.get(messageId);
@@ -95,6 +95,22 @@ const ids = (messages) => (messages ?? []).map((m) => m.id);
95
95
  (0, chai_1.expect)(store.snapshot()).to.have.length(1);
96
96
  (0, chai_1.expect)(ids(store.read(idAt(0), 10))).to.deep.equal(ids(page));
97
97
  });
98
+ (0, mocha_1.it)("replaces an updated message without changing range coverage", () => {
99
+ const page = makeMessages(5, 20);
100
+ store.record({ before: idAt(0), limit: 5, page });
101
+ const before = store.snapshot();
102
+ const updated = {
103
+ ...page[2],
104
+ data: { i: 2, analysed_by: "detector" },
105
+ attachments: [{ id: "updated-image" }],
106
+ };
107
+ store.recordUpdate(updated);
108
+ const read = store.read(idAt(0), 5);
109
+ (0, chai_1.expect)(read[2]).to.equal(updated);
110
+ (0, chai_1.expect)(ids(read)).to.deep.equal(ids(page));
111
+ (0, chai_1.expect)(store.snapshot().map(({ lo, hi, atStart }) => ({ lo, hi, atStart })))
112
+ .to.deep.equal(before.map(({ lo, hi, atStart }) => ({ lo, hi, atStart })));
113
+ });
98
114
  (0, mocha_1.it)("keeps disjoint ranges apart rather than claiming the gap", () => {
99
115
  const ancient = makeMessages(5, 500);
100
116
  const recent = makeMessages(5, 20);
@@ -208,7 +208,7 @@ function wrapper(client) {
208
208
  const cached = queryClient.getQueryData((0, react_2.channelAggregateQueryKey)("a1", "ui_cmds"));
209
209
  (0, chai_1.expect)(cached).to.deep.include({ data: { y: 2 }, attachments: [] });
210
210
  });
211
- (0, mocha_1.it)("useChannelMessages paginates and prepends on live messageCreate", async () => {
211
+ (0, mocha_1.it)("useChannelMessages applies live creates and updates", async () => {
212
212
  const oldId = (0, snowflake_1.generateSnowflakeIdAtTime)(new Date("2025-12-31T23:59:00.000Z"));
213
213
  const newId = (0, snowflake_1.generateSnowflakeIdAtTime)(new Date("2026-01-01T00:00:01.000Z"));
214
214
  const liveId = (0, snowflake_1.generateSnowflakeIdAtTime)(new Date("2026-01-01T00:00:02.000Z"));
@@ -269,6 +269,28 @@ function wrapper(client) {
269
269
  newId,
270
270
  liveId,
271
271
  ]));
272
+ await (0, react_1.act)(async () => {
273
+ helpers_1.MockWebSocket.instances[0].receive({
274
+ op: 0,
275
+ t: "MessageUpdate",
276
+ d: {
277
+ message: {
278
+ id: newId,
279
+ author_id: "u1",
280
+ channel: { agent_id: "a1", name: "notes" },
281
+ data: { v: 22, analysed_by: "detector" },
282
+ attachments: [{ id: "analysed-image" }],
283
+ },
284
+ request_data: {},
285
+ },
286
+ });
287
+ });
288
+ await (0, react_1.waitFor)(() => {
289
+ const updated = result.current.messages.find((message) => message.id === newId);
290
+ (0, chai_1.expect)(updated?.data).to.deep.equal({ v: 22, analysed_by: "detector" });
291
+ (0, chai_1.expect)(updated?.attachments).to.have.length(1);
292
+ (0, chai_1.expect)(updated?.attachments?.[0]).to.deep.include({ id: "analysed-image" });
293
+ });
272
294
  });
273
295
  (0, mocha_1.it)("useChannelMessage seeds via REST and patches on MessageUpdate", async () => {
274
296
  const messageId = (0, snowflake_1.generateSnowflakeIdAtTime)(new Date("2026-01-01T00:00:00.000Z"));
@@ -188,3 +188,64 @@ describe("RpcDispatcher stats integration", () => {
188
188
  (0, chai_1.expect)(snap.rpc.peakPendingRpcs).to.equal(1);
189
189
  });
190
190
  });
191
+ describe("RpcDispatcher pending timeout", () => {
192
+ const setup = (id) => {
193
+ const gw = makeFakeGateway();
194
+ const messages = makeFakeMessagesApi();
195
+ messages.setNextId(id);
196
+ const dispatcher = new rpc_dispatcher_1.RpcDispatcher(gw, messages);
197
+ return { gw, dispatcher, channel: { agent_id: "a1", name: "c1" } };
198
+ };
199
+ it("replaces the initial timeout once the device answers", async () => {
200
+ const { gw, dispatcher, channel } = setup("rpc-p1");
201
+ const promise = dispatcher.send({ agentId: "a1", channelName: "c1" }, { method: "do", request: {} }, { timeoutMs: 10, pendingTimeoutMs: 200 });
202
+ await new Promise((r) => setImmediate(r));
203
+ // Acknowledged before the 10ms deadline: the command must survive well past
204
+ // it, because the device has proven it is working.
205
+ gw.emitMessageUpdate(rpcMessage("rpc-p1", channel, { code: "acknowledged", message: { timestamp: 1 } }, {}));
206
+ await new Promise((r) => setTimeout(r, 60));
207
+ gw.emitMessageUpdate(rpcMessage("rpc-p1", channel, { code: "success" }, {}, { ok: true }));
208
+ (0, chai_1.expect)(await promise).to.deep.equal({ ok: true });
209
+ });
210
+ it("re-arms the pending budget on every progress report", async () => {
211
+ const { gw, dispatcher, channel } = setup("rpc-p2");
212
+ const promise = dispatcher.send({ agentId: "a1", channelName: "c1" }, { method: "do", request: {} }, { timeoutMs: 10, pendingTimeoutMs: 50 });
213
+ await new Promise((r) => setImmediate(r));
214
+ // Four reports, each well inside the 50ms budget but cumulatively past it.
215
+ for (let i = 0; i < 4; i += 1) {
216
+ gw.emitMessageUpdate(rpcMessage("rpc-p2", channel, { code: "pending", message: { text: `step ${i}` } }, {}));
217
+ await new Promise((r) => setTimeout(r, 30));
218
+ }
219
+ gw.emitMessageUpdate(rpcMessage("rpc-p2", channel, { code: "success" }, {}, { ok: 1 }));
220
+ (0, chai_1.expect)(await promise).to.deep.equal({ ok: 1 });
221
+ });
222
+ it("fails a device that answers and then goes quiet", async () => {
223
+ const { gw, dispatcher, channel } = setup("rpc-p3");
224
+ const promise = dispatcher.send({ agentId: "a1", channelName: "c1" }, { method: "do", request: {} }, { timeoutMs: 1000, pendingTimeoutMs: 20 });
225
+ await new Promise((r) => setImmediate(r));
226
+ gw.emitMessageUpdate(rpcMessage("rpc-p3", channel, { code: "acknowledged", message: { timestamp: 1 } }, {}));
227
+ let caught;
228
+ try {
229
+ await promise;
230
+ }
231
+ catch (e) {
232
+ caught = e;
233
+ }
234
+ // Distinct from "RPC timed out": it answered, then stopped.
235
+ (0, chai_1.expect)(caught?.message).to.equal("Device stopped reporting progress");
236
+ });
237
+ it("keeps the flat deadline when no pending budget is given", async () => {
238
+ const { gw, dispatcher, channel } = setup("rpc-p4");
239
+ const promise = dispatcher.send({ agentId: "a1", channelName: "c1" }, { method: "do", request: {} }, { timeoutMs: 20 });
240
+ await new Promise((r) => setImmediate(r));
241
+ gw.emitMessageUpdate(rpcMessage("rpc-p4", channel, { code: "acknowledged", message: { timestamp: 1 } }, {}));
242
+ let caught;
243
+ try {
244
+ await promise;
245
+ }
246
+ catch (e) {
247
+ caught = e;
248
+ }
249
+ (0, chai_1.expect)(caught?.message).to.equal("RPC timed out");
250
+ });
251
+ });
@@ -12,6 +12,16 @@ const path_parsing_1 = require("../viewer/path-parsing");
12
12
  (0, chai_1.expect)(extracted.timestamp).to.equal(date.getTime());
13
13
  (0, chai_1.expect)(id).to.match(/^\d+$/);
14
14
  });
15
+ (0, mocha_1.it)("floors a pre-epoch time at the oldest addressable id", () => {
16
+ // Ids are unsigned; the API rejects a negative one outright and fails the whole
17
+ // request, so a caller reaching further back than 2025-01-01 gets the epoch.
18
+ (0, chai_1.expect)((0, snowflake_1.generateSnowflakeIdAtTime)(new Date("2023-11-18T02:58:19.326Z"))).to.equal("0");
19
+ (0, chai_1.expect)((0, snowflake_1.generateSnowflakeIdAtTime)(new Date("2025-01-01T00:00:00.000Z"))).to.equal("0");
20
+ (0, chai_1.expect)((0, snowflake_1.generateSnowflakeIdAtTime)(new Date(0))).to.equal("0");
21
+ });
22
+ (0, mocha_1.it)("rejects an invalid time rather than emitting a garbage id", () => {
23
+ (0, chai_1.expect)(() => (0, snowflake_1.generateSnowflakeIdAtTime)(new Date("nonsense"))).to.throw(TypeError);
24
+ });
15
25
  (0, mocha_1.it)("adds an epoch-ms timestamp to messages", () => {
16
26
  const date = new Date("2026-01-02T03:04:05.000Z");
17
27
  const id = (0, snowflake_1.generateSnowflakeIdAtTime)(date);
@@ -146,9 +146,18 @@ export interface DataSeries {
146
146
  __source?: SourceProvenance;
147
147
  }
148
148
  /**
149
- * Lifecycle status for an RPC. `pending` carries arbitrary intermediate
150
- * payloads (progress updates) emitted by the server while the request is
151
- * in flight.
149
+ * Lifecycle status for an RPC.
150
+ *
151
+ * `sent` is written by the sender; the other non-terminal codes come from the
152
+ * device handling the request. `acknowledged` means it has picked the request
153
+ * up, and `pending` carries arbitrary intermediate payloads (progress updates)
154
+ * it emits while still working — by convention a `{ text }` summary, plus
155
+ * whatever structured fields the handler wants alongside it. Both prove the
156
+ * device is alive, so consumers should treat either as a reason to extend how
157
+ * long they are willing to wait (see `SendRpcOptions.pendingTimeoutMs`), never
158
+ * as a completion.
159
+ *
160
+ * `success` and `error` are the only terminal codes.
152
161
  */
153
162
  export type RpcStatus<TPending = undefined> = {
154
163
  code: "awaiting_confirmation";
@@ -1,3 +1,11 @@
1
+ /**
2
+ * Snowflake id for a point in time, floored at the epoch.
3
+ *
4
+ * Ids are unsigned, so a pre-epoch time has no id to generate. Returning the
5
+ * negative arithmetic result produced a value the API rejects outright
6
+ * ("expected a snowflake id value"), failing the whole request; `0` is the
7
+ * oldest addressable id, which is what a caller reaching further back wants.
8
+ */
1
9
  export declare function generateSnowflakeIdAtTime(time: {
2
10
  valueOf(): number;
3
11
  }): string;
@@ -3,10 +3,22 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.generateSnowflakeIdAtTime = generateSnowflakeIdAtTime;
4
4
  exports.extractSnowflakeId = extractSnowflakeId;
5
5
  exports.addTimestampToMessage = addTimestampToMessage;
6
+ /** Doover's snowflake epoch: 2025-01-01T00:00:00Z. Nothing predates it. */
7
+ const SNOWFLAKE_EPOCH_MS = 1735689600000;
8
+ /**
9
+ * Snowflake id for a point in time, floored at the epoch.
10
+ *
11
+ * Ids are unsigned, so a pre-epoch time has no id to generate. Returning the
12
+ * negative arithmetic result produced a value the API rejects outright
13
+ * ("expected a snowflake id value"), failing the whole request; `0` is the
14
+ * oldest addressable id, which is what a caller reaching further back wants.
15
+ */
6
16
  function generateSnowflakeIdAtTime(time) {
7
- const offset = 1735689600000;
8
- const bigTime = BigInt(time.valueOf() - offset);
9
- const bigId = bigTime << 22n;
17
+ const offsetMs = time.valueOf() - SNOWFLAKE_EPOCH_MS;
18
+ if (Number.isNaN(offsetMs)) {
19
+ throw new TypeError("generateSnowflakeIdAtTime received an invalid time");
20
+ }
21
+ const bigId = BigInt(Math.max(0, offsetMs)) << 22n;
10
22
  return bigId.toString();
11
23
  }
12
24
  function extractSnowflakeId(id) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doover-js",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "TypeScript client for Doover.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",