doover-js 0.9.0 → 0.9.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.
@@ -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,
@@ -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"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doover-js",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "TypeScript client for Doover.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",