doover-js 0.7.0 → 0.8.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/dist/apis/aggregates-api.d.ts +21 -0
- package/dist/apis/aggregates-api.js +18 -0
- package/dist/apis/messages-api.d.ts +38 -0
- package/dist/apis/messages-api.js +57 -4
- package/dist/client/multiplex-client.d.ts +14 -0
- package/dist/client/multiplex-client.js +76 -0
- package/dist/client/offline-cache.js +5 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -1
- package/dist/react/useChannelMessages.js +45 -15
- package/dist/test/apis.test.js +30 -0
- package/dist/test/batch-apis.test.d.ts +1 -0
- package/dist/test/batch-apis.test.js +183 -0
- package/dist/test/react-channel-messages-forward.test.d.ts +1 -0
- package/dist/test/react-channel-messages-forward.test.js +139 -0
- package/dist/test/react-multiagent-messages.test.d.ts +1 -0
- package/dist/test/react-multiagent-messages.test.js +113 -0
- package/dist/types/batch.d.ts +85 -0
- package/dist/types/batch.js +47 -0
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { RestClient } from "../http/rest-client";
|
|
2
2
|
import type { Aggregate } from "../types/openapi";
|
|
3
3
|
import type { DooverRequestOptions } from "../client/request-options";
|
|
4
|
+
import { type BatchAggregateResponse, type BatchAggregateUpdateItem } from "../types/batch";
|
|
4
5
|
export interface AggregateMutationParams {
|
|
5
6
|
suppress_response?: boolean;
|
|
6
7
|
clear_attachments?: boolean;
|
|
@@ -9,6 +10,26 @@ export interface AggregateMutationParams {
|
|
|
9
10
|
export declare class AggregatesApi {
|
|
10
11
|
private readonly rest;
|
|
11
12
|
constructor(rest: RestClient);
|
|
13
|
+
/**
|
|
14
|
+
* Merge-patch aggregates across many agents and channels in one request.
|
|
15
|
+
*
|
|
16
|
+
* Replaces N single-agent PATCHes with one round trip, which is the point:
|
|
17
|
+
* it removes the per-request HTTP, auth, routing and tracing overhead, and
|
|
18
|
+
* shrinks synchronised write bursts. It does *not* reduce DynamoDB writes —
|
|
19
|
+
* the server still issues one `UpdateItem` per aggregate, capped at four
|
|
20
|
+
* concurrently — so callers should still coalesce on the client rather than
|
|
21
|
+
* relying on this to absorb a storm.
|
|
22
|
+
*
|
|
23
|
+
* Batches over {@link MAX_BATCH_ITEMS} are split automatically and the
|
|
24
|
+
* responses merged, so a caller can pass an arbitrarily long list. An empty
|
|
25
|
+
* list resolves to an empty response without issuing a request.
|
|
26
|
+
*
|
|
27
|
+
* Partial success is normal: inspect `items` and retry only the entries
|
|
28
|
+
* whose `success` is false. Successful entries are never rolled back.
|
|
29
|
+
*/
|
|
30
|
+
batchPatchAggregates(items: BatchAggregateUpdateItem[]): Promise<BatchAggregateResponse>;
|
|
31
|
+
batchPatchAggregates(items: BatchAggregateUpdateItem[], requestOptions: DooverRequestOptions): Promise<BatchAggregateResponse>;
|
|
32
|
+
private _batchPatchAggregates;
|
|
12
33
|
getAggregate(agentId: string, channelName: string): Promise<Aggregate>;
|
|
13
34
|
getAggregate(agentId: string, channelName: string, requestOptions: DooverRequestOptions): Promise<Aggregate>;
|
|
14
35
|
getAggregate(identifier: {
|
|
@@ -2,10 +2,28 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.AggregatesApi = void 0;
|
|
4
4
|
const _args_1 = require("./_args");
|
|
5
|
+
const batch_1 = require("../types/batch");
|
|
5
6
|
class AggregatesApi {
|
|
6
7
|
constructor(rest) {
|
|
7
8
|
this.rest = rest;
|
|
8
9
|
}
|
|
10
|
+
batchPatchAggregates(...args) {
|
|
11
|
+
return this._batchPatchAggregates(args[0]);
|
|
12
|
+
}
|
|
13
|
+
async _batchPatchAggregates(items) {
|
|
14
|
+
if (items.length === 0) {
|
|
15
|
+
return { items: [], count: 0, succeeded: 0, failed: 0 };
|
|
16
|
+
}
|
|
17
|
+
const responses = [];
|
|
18
|
+
// Sequential: the point of batching is to stop hammering the write path,
|
|
19
|
+
// so firing every chunk at once would defeat it.
|
|
20
|
+
for (const chunk of (0, batch_1.chunkBatchItems)(items)) {
|
|
21
|
+
responses.push(await this.rest.patch("/agents/aggregates", {
|
|
22
|
+
items: chunk,
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
return (0, batch_1.mergeBatchResponses)(responses);
|
|
26
|
+
}
|
|
9
27
|
getAggregate(...args) {
|
|
10
28
|
const { agentId, channelName } = (0, _args_1.resolveChannelArgs)(args);
|
|
11
29
|
return this._getAggregate(agentId, channelName);
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import type { RestClient } from "../http/rest-client";
|
|
2
2
|
import type { CreateMessageRequest, DataSeries, MessageStructure, UpdateMessageRequest } from "../types/openapi";
|
|
3
3
|
import type { DooverRequestOptions } from "../client/request-options";
|
|
4
|
+
import { type BatchCreateMessageItem, type BatchDeleteMessageItem, type BatchMessageResponse, type BatchUpdateMessageItem } from "../types/batch";
|
|
4
5
|
export interface ListMessagesParams {
|
|
5
6
|
before?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Lower-bound cursor (exclusive). With `before` set (or defaulted) it merely
|
|
9
|
+
* floors the window. On its own — `after` set, `before` omitted — it pages
|
|
10
|
+
* *forward*: the server returns the `limit` **oldest** messages strictly
|
|
11
|
+
* newer than the cursor, oldest-first. This is the only way to walk history
|
|
12
|
+
* upward toward "now".
|
|
13
|
+
*/
|
|
6
14
|
after?: string;
|
|
7
15
|
limit?: number;
|
|
8
16
|
field_name?: string[];
|
|
@@ -86,5 +94,35 @@ export declare class MessagesApi {
|
|
|
86
94
|
channelName: string;
|
|
87
95
|
}, messageId: string): Promise<TLog[]>;
|
|
88
96
|
private _getInvocationLogs;
|
|
97
|
+
/**
|
|
98
|
+
* Create messages across many agents and channels in one request.
|
|
99
|
+
*
|
|
100
|
+
* Supply `message_id` per item where you can: the server's create path is
|
|
101
|
+
* not yet idempotent, so a retry after a lost response can otherwise
|
|
102
|
+
* duplicate the message and double-count the daily summary.
|
|
103
|
+
*/
|
|
104
|
+
batchPostMessages(items: BatchCreateMessageItem[]): Promise<BatchMessageResponse>;
|
|
105
|
+
batchPostMessages(items: BatchCreateMessageItem[], requestOptions: DooverRequestOptions): Promise<BatchMessageResponse>;
|
|
106
|
+
/** Merge-patch messages across many agents and channels in one request. */
|
|
107
|
+
batchPatchMessages(items: BatchUpdateMessageItem[]): Promise<BatchMessageResponse>;
|
|
108
|
+
batchPatchMessages(items: BatchUpdateMessageItem[], requestOptions: DooverRequestOptions): Promise<BatchMessageResponse>;
|
|
109
|
+
/** Replace messages across many agents and channels in one request. */
|
|
110
|
+
batchPutMessages(items: BatchUpdateMessageItem[]): Promise<BatchMessageResponse>;
|
|
111
|
+
batchPutMessages(items: BatchUpdateMessageItem[], requestOptions: DooverRequestOptions): Promise<BatchMessageResponse>;
|
|
112
|
+
/**
|
|
113
|
+
* Delete messages across many agents and channels in one request.
|
|
114
|
+
*
|
|
115
|
+
* Deletion is not idempotent server-side yet — a retried delete can
|
|
116
|
+
* decrement the daily summary twice — so retry only items the response
|
|
117
|
+
* reported as failed.
|
|
118
|
+
*/
|
|
119
|
+
batchDeleteMessages(items: BatchDeleteMessageItem[]): Promise<BatchMessageResponse>;
|
|
120
|
+
batchDeleteMessages(items: BatchDeleteMessageItem[], requestOptions: DooverRequestOptions): Promise<BatchMessageResponse>;
|
|
121
|
+
/**
|
|
122
|
+
* Shared driver for the four `/agents/messages` verbs. Chunks to the
|
|
123
|
+
* server's item ceiling and sends sequentially, so a long list doesn't
|
|
124
|
+
* reintroduce the burst that batching exists to remove.
|
|
125
|
+
*/
|
|
126
|
+
private _batchMessages;
|
|
89
127
|
createMultipartPayload(jsonPayload: Record<string, unknown>, attachments: Array<Blob | File>): FormData;
|
|
90
128
|
}
|
|
@@ -3,17 +3,34 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.MessagesApi = void 0;
|
|
4
4
|
const snowflake_1 = require("../utils/snowflake");
|
|
5
5
|
const _args_1 = require("./_args");
|
|
6
|
+
const batch_1 = require("../types/batch");
|
|
6
7
|
function resolveListMessagesParams(params) {
|
|
7
8
|
const order = params?.order ?? "desc";
|
|
9
|
+
const hasAfter = params?.after !== undefined;
|
|
10
|
+
const hasBefore = params?.before !== undefined;
|
|
11
|
+
// The server keys pagination on whichever bound is sent: `before` walks
|
|
12
|
+
// newest-first downward, `after` alone walks oldest-first upward (the N
|
|
13
|
+
// oldest messages strictly newer than the cursor). Sending `after` without
|
|
14
|
+
// `before` is the only way to page forward; sending both, or neither, keeps
|
|
15
|
+
// the newest-first behaviour (defaulting `before` to "now" when unbounded).
|
|
16
|
+
if (hasAfter && !hasBefore) {
|
|
17
|
+
const query = {
|
|
18
|
+
after: params.after,
|
|
19
|
+
limit: params?.limit ?? 10,
|
|
20
|
+
};
|
|
21
|
+
if (params?.field_name !== undefined)
|
|
22
|
+
query.field_name = params.field_name;
|
|
23
|
+
return { query, order, serverAscending: true };
|
|
24
|
+
}
|
|
8
25
|
const query = {
|
|
9
26
|
before: params?.before ?? (0, snowflake_1.generateSnowflakeIdAtTime)(new Date()),
|
|
10
27
|
limit: params?.limit ?? 10,
|
|
11
28
|
};
|
|
12
|
-
if (
|
|
29
|
+
if (hasAfter)
|
|
13
30
|
query.after = params.after;
|
|
14
31
|
if (params?.field_name !== undefined)
|
|
15
32
|
query.field_name = params.field_name;
|
|
16
|
-
return { query, order };
|
|
33
|
+
return { query, order, serverAscending: false };
|
|
17
34
|
}
|
|
18
35
|
class MessagesApi {
|
|
19
36
|
constructor(rest) {
|
|
@@ -24,10 +41,13 @@ class MessagesApi {
|
|
|
24
41
|
return this._listMessages(agentId, channelName, options);
|
|
25
42
|
}
|
|
26
43
|
async _listMessages(agentId, channelName, params) {
|
|
27
|
-
const { query, order } = resolveListMessagesParams(params);
|
|
44
|
+
const { query, order, serverAscending } = resolveListMessagesParams(params);
|
|
28
45
|
const response = await this.rest.get(`/agents/${agentId}/channels/${channelName}/messages`, query);
|
|
29
46
|
const stamped = response.map(snowflake_1.addTimestampToMessage);
|
|
30
|
-
|
|
47
|
+
// The server returns newest-first for `before` and oldest-first for a bare
|
|
48
|
+
// `after`; reverse only when that native order differs from what was asked.
|
|
49
|
+
const wantAscending = order === "asc";
|
|
50
|
+
return serverAscending === wantAscending ? stamped : [...stamped].reverse();
|
|
31
51
|
}
|
|
32
52
|
async postMessage(...args) {
|
|
33
53
|
if (typeof args[0] === "string") {
|
|
@@ -118,6 +138,39 @@ class MessagesApi {
|
|
|
118
138
|
_getInvocationLogs(agentId, channelName, messageId) {
|
|
119
139
|
return this.rest.get(`/agents/${agentId}/channels/${channelName}/messages/${messageId}/logs`);
|
|
120
140
|
}
|
|
141
|
+
batchPostMessages(...args) {
|
|
142
|
+
return this._batchMessages("POST", args[0]);
|
|
143
|
+
}
|
|
144
|
+
batchPatchMessages(...args) {
|
|
145
|
+
return this._batchMessages("PATCH", args[0]);
|
|
146
|
+
}
|
|
147
|
+
batchPutMessages(...args) {
|
|
148
|
+
return this._batchMessages("PUT", args[0]);
|
|
149
|
+
}
|
|
150
|
+
batchDeleteMessages(...args) {
|
|
151
|
+
return this._batchMessages("DELETE", args[0]);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Shared driver for the four `/agents/messages` verbs. Chunks to the
|
|
155
|
+
* server's item ceiling and sends sequentially, so a long list doesn't
|
|
156
|
+
* reintroduce the burst that batching exists to remove.
|
|
157
|
+
*/
|
|
158
|
+
async _batchMessages(method, items) {
|
|
159
|
+
if (items.length === 0) {
|
|
160
|
+
return { items: [], count: 0, succeeded: 0, failed: 0 };
|
|
161
|
+
}
|
|
162
|
+
const responses = [];
|
|
163
|
+
for (const chunk of (0, batch_1.chunkBatchItems)(items)) {
|
|
164
|
+
// Goes through `request` rather than the verb helpers because DELETE
|
|
165
|
+
// needs a body, which `rest.delete` does not accept.
|
|
166
|
+
responses.push(await this.rest.request({
|
|
167
|
+
path: "/agents/messages",
|
|
168
|
+
method,
|
|
169
|
+
body: { items: chunk },
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
return (0, batch_1.mergeBatchResponses)(responses);
|
|
173
|
+
}
|
|
121
174
|
createMultipartPayload(jsonPayload, attachments) {
|
|
122
175
|
const formData = new FormData();
|
|
123
176
|
formData.set("json_payload", JSON.stringify(jsonPayload));
|
|
@@ -97,6 +97,20 @@ export declare class MultiplexClient implements DataClient {
|
|
|
97
97
|
private isNotFound;
|
|
98
98
|
private requiredMessagesCapability;
|
|
99
99
|
private unsupportedMethod;
|
|
100
|
+
/**
|
|
101
|
+
* Route a cross-agent batch mutation.
|
|
102
|
+
*
|
|
103
|
+
* A batch can name agents served by different members, so unlike
|
|
104
|
+
* `routedWrite` there is no single target. Each item is resolved to its own
|
|
105
|
+
* member using the same rules (unsupported and ambiguous both throw), the
|
|
106
|
+
* batch is split into one sub-batch per member, and results are stitched
|
|
107
|
+
* back into the caller's original item order.
|
|
108
|
+
*
|
|
109
|
+
* Resolution happens for every item up front so an unroutable item fails
|
|
110
|
+
* the whole call before any member is written to, rather than leaving a
|
|
111
|
+
* half-applied batch behind.
|
|
112
|
+
*/
|
|
113
|
+
private routedBatchWrite;
|
|
100
114
|
private guessNonCoreCap;
|
|
101
115
|
protected recordConflict(method: string, agentId: string, channelName: string, values: Array<{
|
|
102
116
|
value: unknown;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.MultiplexClient = void 0;
|
|
4
4
|
const multiplex_merge_1 = require("./multiplex-merge");
|
|
5
|
+
const batch_1 = require("../types/batch");
|
|
5
6
|
const errors_1 = require("./errors");
|
|
6
7
|
const snowflake_1 = require("../utils/snowflake");
|
|
7
8
|
const multiplex_gateway_1 = require("./multiplex-gateway");
|
|
@@ -293,6 +294,76 @@ class MultiplexClient {
|
|
|
293
294
|
unsupportedMethod(_method, cap) {
|
|
294
295
|
return () => Promise.reject(new errors_1.UnsupportedCapabilityError(cap, this.clientId));
|
|
295
296
|
}
|
|
297
|
+
/**
|
|
298
|
+
* Route a cross-agent batch mutation.
|
|
299
|
+
*
|
|
300
|
+
* A batch can name agents served by different members, so unlike
|
|
301
|
+
* `routedWrite` there is no single target. Each item is resolved to its own
|
|
302
|
+
* member using the same rules (unsupported and ambiguous both throw), the
|
|
303
|
+
* batch is split into one sub-batch per member, and results are stitched
|
|
304
|
+
* back into the caller's original item order.
|
|
305
|
+
*
|
|
306
|
+
* Resolution happens for every item up front so an unroutable item fails
|
|
307
|
+
* the whole call before any member is written to, rather than leaving a
|
|
308
|
+
* half-applied batch behind.
|
|
309
|
+
*/
|
|
310
|
+
routedBatchWrite(method, cap) {
|
|
311
|
+
const self = this;
|
|
312
|
+
return async function (...rawArgs) {
|
|
313
|
+
const { args, sources } = self.splitSourcesOption(rawArgs);
|
|
314
|
+
const items = (args[0] ?? []);
|
|
315
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
316
|
+
return { items: [], count: 0, succeeded: 0, failed: 0 };
|
|
317
|
+
}
|
|
318
|
+
const groups = new Map();
|
|
319
|
+
items.forEach((item, index) => {
|
|
320
|
+
let members = self.candidates(item.agent_id, cap, sources);
|
|
321
|
+
if (sources && sources.length === 1) {
|
|
322
|
+
const only = members.find((m) => m.id === sources[0]);
|
|
323
|
+
if (!only)
|
|
324
|
+
throw new errors_1.UnsupportedCapabilityError(cap, sources[0]);
|
|
325
|
+
members = [only];
|
|
326
|
+
}
|
|
327
|
+
if (members.length === 0)
|
|
328
|
+
throw new errors_1.UnsupportedCapabilityError(cap, self.clientId);
|
|
329
|
+
if (members.length > 1) {
|
|
330
|
+
throw new errors_1.AmbiguousWriteError(cap, members.map((m) => m.id));
|
|
331
|
+
}
|
|
332
|
+
const target = members[0];
|
|
333
|
+
const group = groups.get(target.id) ?? {
|
|
334
|
+
client: target.client,
|
|
335
|
+
items: [],
|
|
336
|
+
indices: [],
|
|
337
|
+
};
|
|
338
|
+
group.items.push(item);
|
|
339
|
+
group.indices.push(index);
|
|
340
|
+
groups.set(target.id, group);
|
|
341
|
+
});
|
|
342
|
+
const subclient = method.split(".")[0];
|
|
343
|
+
const fnName = method.split(".")[1];
|
|
344
|
+
const results = new Array(items.length);
|
|
345
|
+
for (const group of groups.values()) {
|
|
346
|
+
const fn = group.client[subclient][fnName];
|
|
347
|
+
const response = (await fn.apply(group.client[subclient], [
|
|
348
|
+
group.items,
|
|
349
|
+
]));
|
|
350
|
+
group.indices.forEach((originalIndex, i) => {
|
|
351
|
+
const item = response.items[i];
|
|
352
|
+
// A member that returns a short list would otherwise leave holes;
|
|
353
|
+
// record an explicit failure so `count` stays consistent.
|
|
354
|
+
results[originalIndex] = item ?? {
|
|
355
|
+
agent_id: group.items[i].agent_id,
|
|
356
|
+
channel_name: group.items[i].channel_name ?? "",
|
|
357
|
+
success: false,
|
|
358
|
+
error: "no result returned for batch item",
|
|
359
|
+
};
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
return (0, batch_1.mergeBatchResponses)([
|
|
363
|
+
{ items: results, count: results.length, succeeded: 0, failed: 0 },
|
|
364
|
+
]);
|
|
365
|
+
};
|
|
366
|
+
}
|
|
296
367
|
guessNonCoreCap(subclient) {
|
|
297
368
|
switch (subclient) {
|
|
298
369
|
case "alarms": return "alarms.read";
|
|
@@ -459,6 +530,7 @@ class MultiplexClient {
|
|
|
459
530
|
},
|
|
460
531
|
putAggregate: self.routedWrite("aggregates.putAggregate", "aggregates.put"),
|
|
461
532
|
patchAggregate: self.routedWrite("aggregates.patchAggregate", "aggregates.patch"),
|
|
533
|
+
batchPatchAggregates: self.routedBatchWrite("aggregates.batchPatchAggregates", "aggregates.patch"),
|
|
462
534
|
async getAggregateAttachment(agentIdOrId, ...rest) {
|
|
463
535
|
const { args, sources } = self.splitSourcesOption([agentIdOrId, ...rest]);
|
|
464
536
|
const agentId = self.extractAgentId(args);
|
|
@@ -515,6 +587,10 @@ class MultiplexClient {
|
|
|
515
587
|
putMessage: self.routedWrite("messages.putMessage", "messages.put"),
|
|
516
588
|
patchMessage: self.routedWrite("messages.patchMessage", "messages.put"),
|
|
517
589
|
deleteMessage: self.routedWrite("messages.deleteMessage", "messages.delete"),
|
|
590
|
+
batchPostMessages: self.routedBatchWrite("messages.batchPostMessages", "messages.post"),
|
|
591
|
+
batchPatchMessages: self.routedBatchWrite("messages.batchPatchMessages", "messages.put"),
|
|
592
|
+
batchPutMessages: self.routedBatchWrite("messages.batchPutMessages", "messages.put"),
|
|
593
|
+
batchDeleteMessages: self.routedBatchWrite("messages.batchDeleteMessages", "messages.delete"),
|
|
518
594
|
getTimeseries: self.makeFanoutFirst("messages.getTimeseries", "messages.timeseries"),
|
|
519
595
|
getMessageAttachment: self.makeBlobFanout("messages.getMessageAttachment", "messages.attachment"),
|
|
520
596
|
getInvocationLogs: self.makeFanoutFirst("messages.getInvocationLogs", "messages.invocationLogs"),
|
|
@@ -215,6 +215,7 @@ class OfflineDataClient {
|
|
|
215
215
|
}),
|
|
216
216
|
putAggregate: this.offlineGuard("aggregates.putAggregate", target.putAggregate.bind(target)),
|
|
217
217
|
patchAggregate: this.offlineGuard("aggregates.patchAggregate", target.patchAggregate.bind(target)),
|
|
218
|
+
batchPatchAggregates: this.offlineGuard("aggregates.batchPatchAggregates", target.batchPatchAggregates.bind(target)),
|
|
218
219
|
getAggregateAttachment: ((...rawArgs) => {
|
|
219
220
|
const { args, request } = (0, request_options_1.splitRequestOptions)(rawArgs);
|
|
220
221
|
const { agentId, channelName } = extractChannelId(args);
|
|
@@ -267,6 +268,10 @@ class OfflineDataClient {
|
|
|
267
268
|
putMessage: this.offlineGuard("messages.putMessage", target.putMessage.bind(target)),
|
|
268
269
|
patchMessage: this.offlineGuard("messages.patchMessage", target.patchMessage.bind(target)),
|
|
269
270
|
deleteMessage: this.offlineGuard("messages.deleteMessage", target.deleteMessage.bind(target)),
|
|
271
|
+
batchPostMessages: this.offlineGuard("messages.batchPostMessages", target.batchPostMessages.bind(target)),
|
|
272
|
+
batchPatchMessages: this.offlineGuard("messages.batchPatchMessages", target.batchPatchMessages.bind(target)),
|
|
273
|
+
batchPutMessages: this.offlineGuard("messages.batchPutMessages", target.batchPutMessages.bind(target)),
|
|
274
|
+
batchDeleteMessages: this.offlineGuard("messages.batchDeleteMessages", target.batchDeleteMessages.bind(target)),
|
|
270
275
|
getTimeseries: ((...args) => target.getTimeseries(...args)),
|
|
271
276
|
getMessageAttachment: ((...rawArgs) => {
|
|
272
277
|
const { args, request } = (0, request_options_1.splitRequestOptions)(rawArgs);
|
package/dist/index.d.ts
CHANGED
|
@@ -31,6 +31,8 @@ export { OfflineDataClient, MemoryOfflineStorageAdapter, DEFAULT_OFFLINE_RETENTI
|
|
|
31
31
|
export type { OfflineCacheMode, OfflineReadCacheOptions, OfflineChannelPolicy, OfflineCacheScope, OfflineCacheRecord, OfflineStorageAdapter, OfflineDataClientOptions, } from "./client/offline-cache";
|
|
32
32
|
export { requestOptions, isDooverRequestOptions, splitRequestOptions, } from "./client/request-options";
|
|
33
33
|
export type { DooverRequestOptions } from "./client/request-options";
|
|
34
|
+
export { MAX_BATCH_ITEMS, chunkBatchItems, mergeBatchResponses } from "./types/batch";
|
|
35
|
+
export type { BatchAggregateUpdateItem, BatchAggregateResponse, BatchCreateMessageItem, BatchUpdateMessageItem, BatchDeleteMessageItem, BatchMessageResponse, BatchMessageResultItem, BatchResultItem, BatchResponse, } from "./types/batch";
|
|
34
36
|
export type { DataClient, AgentScope, DataClientStatus, DataClientConnectionState, AgentsApiLike, AggregatesApiLike, AlarmsApiLike, ChannelsApiLike, ConnectionsApiLike, MessagesApiLike, NotificationsApiLike, PermissionsApiLike, ProcessorsApiLike, TurnApiLike, UsersApiLike, GatewayClientLike, RpcDispatcherLike, } from "./client/data-client";
|
|
35
37
|
export type { SourceProvenance, SourceProvenanceViaRest, SourceProvenanceViaGateway, } from "./types/provenance";
|
|
36
38
|
export { DooverAuth } from "./auth/doover-auth";
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
3
|
+
exports.extractSnowflakeId = exports.addTimestampToMessage = exports.DV_AUDIT_CHANNEL = exports.getIdentifierFromPath = exports.DooverDataProvider = exports.DooverValidationError = exports.DooverGatewayError = exports.DooverApiError = exports.RestClient = exports.GatewayClient = exports.buildAuth = exports.DooverAuthError = exports.AuthProfile = exports.DooverTokenAuth = exports.CookieAuth = exports.DooverAuth = exports.mergeBatchResponses = exports.chunkBatchItems = exports.MAX_BATCH_ITEMS = exports.splitRequestOptions = exports.isDooverRequestOptions = exports.requestOptions = exports.DEFAULT_OFFLINE_RETENTION_MS = exports.MemoryOfflineStorageAdapter = exports.OfflineDataClient = exports.DooverOfflineError = exports.AmbiguousWriteError = exports.UnsupportedCapabilityError = exports.ALL_CAPABILITIES = exports.DooverRpcError = exports.RpcDispatcher = exports.UsersApi = exports.TurnApi = exports.ProcessorsApi = exports.PermissionsApi = exports.OrganisationsApi = exports.NotificationsApi = exports.MessagesApi = exports.ConnectionsApi = exports.ChannelsApi = exports.AlarmsApi = exports.AggregatesApi = exports.AgentsApi = exports.DooverStatsCollector = exports.resetDooverClient = exports.peekDooverClient = exports.getDooverClient = exports.MultiplexClient = exports.LocalAgentClient = exports.DooverClient = void 0;
|
|
4
|
+
exports.generateSnowflakeIdAtTime = void 0;
|
|
4
5
|
var doover_client_1 = require("./client/doover-client");
|
|
5
6
|
Object.defineProperty(exports, "DooverClient", { enumerable: true, get: function () { return doover_client_1.DooverClient; } });
|
|
6
7
|
var local_agent_client_1 = require("./client/local-agent-client");
|
|
@@ -55,6 +56,10 @@ var request_options_1 = require("./client/request-options");
|
|
|
55
56
|
Object.defineProperty(exports, "requestOptions", { enumerable: true, get: function () { return request_options_1.requestOptions; } });
|
|
56
57
|
Object.defineProperty(exports, "isDooverRequestOptions", { enumerable: true, get: function () { return request_options_1.isDooverRequestOptions; } });
|
|
57
58
|
Object.defineProperty(exports, "splitRequestOptions", { enumerable: true, get: function () { return request_options_1.splitRequestOptions; } });
|
|
59
|
+
var batch_1 = require("./types/batch");
|
|
60
|
+
Object.defineProperty(exports, "MAX_BATCH_ITEMS", { enumerable: true, get: function () { return batch_1.MAX_BATCH_ITEMS; } });
|
|
61
|
+
Object.defineProperty(exports, "chunkBatchItems", { enumerable: true, get: function () { return batch_1.chunkBatchItems; } });
|
|
62
|
+
Object.defineProperty(exports, "mergeBatchResponses", { enumerable: true, get: function () { return batch_1.mergeBatchResponses; } });
|
|
58
63
|
var doover_auth_1 = require("./auth/doover-auth");
|
|
59
64
|
Object.defineProperty(exports, "DooverAuth", { enumerable: true, get: function () { return doover_auth_1.DooverAuth; } });
|
|
60
65
|
var cookie_auth_1 = require("./auth/cookie-auth");
|
|
@@ -99,38 +99,68 @@ function useChannelMessages(identifier, options) {
|
|
|
99
99
|
queryKey: key,
|
|
100
100
|
enabled: !!agentId && !!channelName,
|
|
101
101
|
staleTime: Infinity,
|
|
102
|
-
initialPageParam:
|
|
103
|
-
|
|
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
|
+
},
|
|
104
123
|
queryFn: async ({ pageParam }) => {
|
|
105
124
|
if (!agentId || !channelName)
|
|
106
125
|
return [];
|
|
107
126
|
const id = { agentId, channelName };
|
|
108
|
-
|
|
109
|
-
// range store records the exact bound the request proved.
|
|
110
|
-
const before = typeof pageParam === "string"
|
|
111
|
-
? pageParam
|
|
112
|
-
: (0, snowflake_1.generateSnowflakeIdAtTime)(new Date());
|
|
127
|
+
const param = (pageParam ?? {});
|
|
113
128
|
// Mirrors the API's own default; the store needs the limit actually applied
|
|
114
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());
|
|
115
151
|
if (rangeCacheable) {
|
|
116
152
|
const cached = store.read(BigInt(before), effectiveLimit);
|
|
117
153
|
if (cached)
|
|
118
154
|
return cached;
|
|
119
155
|
}
|
|
120
156
|
const requestedAt = Date.now();
|
|
121
|
-
const
|
|
157
|
+
const page = await runList({
|
|
122
158
|
before,
|
|
123
159
|
...(limit !== undefined ? { limit } : {}),
|
|
124
160
|
...(fields && fields.length > 0 ? { field_name: fields } : {}),
|
|
125
161
|
...(after !== undefined ? { after } : {}),
|
|
126
162
|
order: "asc",
|
|
127
|
-
};
|
|
128
|
-
// Pass `{ sources }` as a trailing bag only when set — cast through
|
|
129
|
-
// `never` since the TypeScript overloads don't declare it.
|
|
130
|
-
const sourcesArg = sources ? { sources } : undefined;
|
|
131
|
-
const page = sourcesArg
|
|
132
|
-
? await client.messages.listMessages(id, params, sourcesArg)
|
|
133
|
-
: await client.messages.listMessages(id, params);
|
|
163
|
+
});
|
|
134
164
|
if (rangeCacheable) {
|
|
135
165
|
store.record({
|
|
136
166
|
before: BigInt(before),
|
package/dist/test/apis.test.js
CHANGED
|
@@ -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,183 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const chai_1 = require("chai");
|
|
4
|
+
const mocha_1 = require("mocha");
|
|
5
|
+
const aggregates_api_1 = require("../apis/aggregates-api");
|
|
6
|
+
const messages_api_1 = require("../apis/messages-api");
|
|
7
|
+
const rest_client_1 = require("../http/rest-client");
|
|
8
|
+
const batch_1 = require("../types/batch");
|
|
9
|
+
const helpers_1 = require("./helpers");
|
|
10
|
+
/**
|
|
11
|
+
* Rest client whose fetch mock records every request and replies with a
|
|
12
|
+
* well-formed batch response echoing the submitted items as successes.
|
|
13
|
+
*/
|
|
14
|
+
function setupBatchRest(overrides) {
|
|
15
|
+
const requests = [];
|
|
16
|
+
const fetchMock = (0, helpers_1.createFetchMock)((url, init) => {
|
|
17
|
+
const body = JSON.parse(String(init?.body ?? "{}"));
|
|
18
|
+
const captured = { url, method: init?.method, body };
|
|
19
|
+
requests.push(captured);
|
|
20
|
+
const override = overrides?.(captured);
|
|
21
|
+
if (override)
|
|
22
|
+
return (0, helpers_1.createJsonResponse)(override);
|
|
23
|
+
const items = body.items.map((item) => ({
|
|
24
|
+
agent_id: item.agent_id,
|
|
25
|
+
channel_name: item.channel_name,
|
|
26
|
+
success: true,
|
|
27
|
+
}));
|
|
28
|
+
return (0, helpers_1.createJsonResponse)({
|
|
29
|
+
items,
|
|
30
|
+
count: items.length,
|
|
31
|
+
succeeded: items.length,
|
|
32
|
+
failed: 0,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
const rest = new rest_client_1.RestClient({
|
|
36
|
+
dataRestUrl: "https://api.example.com",
|
|
37
|
+
controlApiUrl: "https://control.example.com",
|
|
38
|
+
dataWssUrl: "wss://ws.example.com",
|
|
39
|
+
fetchImpl: fetchMock,
|
|
40
|
+
});
|
|
41
|
+
return { rest, requests };
|
|
42
|
+
}
|
|
43
|
+
function aggregateItems(count) {
|
|
44
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
45
|
+
agent_id: `agent-${i}`,
|
|
46
|
+
channel_name: "dv-ui-sub",
|
|
47
|
+
data: { group_open: { "u1:s1": 1 } },
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
(0, mocha_1.describe)("batch helpers", () => {
|
|
51
|
+
(0, mocha_1.it)("chunks to the server ceiling and preserves order", () => {
|
|
52
|
+
const chunks = (0, batch_1.chunkBatchItems)([1, 2, 3, 4, 5], 2);
|
|
53
|
+
(0, chai_1.expect)(chunks).to.deep.equal([[1, 2], [3, 4], [5]]);
|
|
54
|
+
});
|
|
55
|
+
(0, mocha_1.it)("returns no chunks for an empty list", () => {
|
|
56
|
+
(0, chai_1.expect)((0, batch_1.chunkBatchItems)([])).to.deep.equal([]);
|
|
57
|
+
});
|
|
58
|
+
(0, mocha_1.it)("rejects a nonsensical chunk size rather than looping forever", () => {
|
|
59
|
+
(0, chai_1.expect)(() => (0, batch_1.chunkBatchItems)([1], 0)).to.throw(RangeError);
|
|
60
|
+
});
|
|
61
|
+
(0, mocha_1.it)("recomputes counts when merging responses", () => {
|
|
62
|
+
const merged = (0, batch_1.mergeBatchResponses)([
|
|
63
|
+
{
|
|
64
|
+
items: [{ agent_id: "a", channel_name: "c", success: true }],
|
|
65
|
+
count: 1,
|
|
66
|
+
succeeded: 1,
|
|
67
|
+
failed: 0,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
items: [
|
|
71
|
+
{ agent_id: "b", channel_name: "c", success: false, error: "boom" },
|
|
72
|
+
],
|
|
73
|
+
count: 1,
|
|
74
|
+
succeeded: 1, // deliberately wrong: merge must not trust these
|
|
75
|
+
failed: 0,
|
|
76
|
+
},
|
|
77
|
+
]);
|
|
78
|
+
(0, chai_1.expect)(merged.count).to.equal(2);
|
|
79
|
+
(0, chai_1.expect)(merged.succeeded).to.equal(1);
|
|
80
|
+
(0, chai_1.expect)(merged.failed).to.equal(1);
|
|
81
|
+
(0, chai_1.expect)(merged.items.map((i) => i.agent_id)).to.deep.equal(["a", "b"]);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
(0, mocha_1.describe)("AggregatesApi.batchPatchAggregates", () => {
|
|
85
|
+
(0, mocha_1.it)("PATCHes /agents/aggregates with an items envelope", async () => {
|
|
86
|
+
const { rest, requests } = setupBatchRest();
|
|
87
|
+
const api = new aggregates_api_1.AggregatesApi(rest);
|
|
88
|
+
const response = await api.batchPatchAggregates(aggregateItems(2));
|
|
89
|
+
(0, chai_1.expect)(requests).to.have.length(1);
|
|
90
|
+
(0, chai_1.expect)(requests[0].url).to.equal("https://api.example.com/agents/aggregates");
|
|
91
|
+
(0, chai_1.expect)(requests[0].method).to.equal("PATCH");
|
|
92
|
+
(0, chai_1.expect)(requests[0].body.items).to.have.length(2);
|
|
93
|
+
(0, chai_1.expect)(requests[0].body.items[0]).to.deep.equal({
|
|
94
|
+
agent_id: "agent-0",
|
|
95
|
+
channel_name: "dv-ui-sub",
|
|
96
|
+
data: { group_open: { "u1:s1": 1 } },
|
|
97
|
+
});
|
|
98
|
+
(0, chai_1.expect)(response.count).to.equal(2);
|
|
99
|
+
(0, chai_1.expect)(response.succeeded).to.equal(2);
|
|
100
|
+
});
|
|
101
|
+
(0, mocha_1.it)("issues no request at all for an empty batch", async () => {
|
|
102
|
+
const { rest, requests } = setupBatchRest();
|
|
103
|
+
const api = new aggregates_api_1.AggregatesApi(rest);
|
|
104
|
+
const response = await api.batchPatchAggregates([]);
|
|
105
|
+
(0, chai_1.expect)(requests).to.have.length(0);
|
|
106
|
+
(0, chai_1.expect)(response).to.deep.equal({ items: [], count: 0, succeeded: 0, failed: 0 });
|
|
107
|
+
});
|
|
108
|
+
(0, mocha_1.it)("splits oversized batches and merges the responses in order", async () => {
|
|
109
|
+
const { rest, requests } = setupBatchRest();
|
|
110
|
+
const api = new aggregates_api_1.AggregatesApi(rest);
|
|
111
|
+
const total = batch_1.MAX_BATCH_ITEMS + 5;
|
|
112
|
+
const response = await api.batchPatchAggregates(aggregateItems(total));
|
|
113
|
+
(0, chai_1.expect)(requests).to.have.length(2);
|
|
114
|
+
(0, chai_1.expect)(requests[0].body.items).to.have.length(batch_1.MAX_BATCH_ITEMS);
|
|
115
|
+
(0, chai_1.expect)(requests[1].body.items).to.have.length(5);
|
|
116
|
+
(0, chai_1.expect)(response.count).to.equal(total);
|
|
117
|
+
(0, chai_1.expect)(response.items[0].agent_id).to.equal("agent-0");
|
|
118
|
+
(0, chai_1.expect)(response.items[total - 1].agent_id).to.equal(`agent-${total - 1}`);
|
|
119
|
+
});
|
|
120
|
+
(0, mocha_1.it)("surfaces partial failure without throwing", async () => {
|
|
121
|
+
const { rest } = setupBatchRest(() => ({
|
|
122
|
+
items: [
|
|
123
|
+
{ agent_id: "agent-0", channel_name: "dv-ui-sub", success: true },
|
|
124
|
+
{
|
|
125
|
+
agent_id: "agent-1",
|
|
126
|
+
channel_name: "dv-ui-sub",
|
|
127
|
+
success: false,
|
|
128
|
+
error: "permission denied",
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
count: 2,
|
|
132
|
+
succeeded: 1,
|
|
133
|
+
failed: 1,
|
|
134
|
+
}));
|
|
135
|
+
const api = new aggregates_api_1.AggregatesApi(rest);
|
|
136
|
+
const response = await api.batchPatchAggregates(aggregateItems(2));
|
|
137
|
+
(0, chai_1.expect)(response.failed).to.equal(1);
|
|
138
|
+
(0, chai_1.expect)(response.items[1].error).to.equal("permission denied");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
(0, mocha_1.describe)("MessagesApi batch mutations", () => {
|
|
142
|
+
(0, mocha_1.it)("POSTs creates to /agents/messages", async () => {
|
|
143
|
+
const { rest, requests } = setupBatchRest();
|
|
144
|
+
const api = new messages_api_1.MessagesApi(rest);
|
|
145
|
+
await api.batchPostMessages([
|
|
146
|
+
{ agent_id: "a1", channel_name: "activity", message_id: "m1", data: { event: "opened" } },
|
|
147
|
+
]);
|
|
148
|
+
(0, chai_1.expect)(requests[0].url).to.equal("https://api.example.com/agents/messages");
|
|
149
|
+
(0, chai_1.expect)(requests[0].method).to.equal("POST");
|
|
150
|
+
(0, chai_1.expect)(requests[0].body.items[0].message_id).to.equal("m1");
|
|
151
|
+
});
|
|
152
|
+
(0, mocha_1.it)("uses the matching verb for patch, put and delete", async () => {
|
|
153
|
+
const { rest, requests } = setupBatchRest();
|
|
154
|
+
const api = new messages_api_1.MessagesApi(rest);
|
|
155
|
+
const items = [{ agent_id: "a1", channel_name: "c1", message_id: "m1", data: {} }];
|
|
156
|
+
await api.batchPatchMessages(items);
|
|
157
|
+
await api.batchPutMessages(items);
|
|
158
|
+
await api.batchDeleteMessages([{ agent_id: "a1", channel_name: "c1", message_id: "m1" }]);
|
|
159
|
+
(0, chai_1.expect)(requests.map((r) => r.method)).to.deep.equal(["PATCH", "PUT", "DELETE"]);
|
|
160
|
+
// DELETE must carry a body — the reason these bypass `rest.delete`.
|
|
161
|
+
(0, chai_1.expect)(requests[2].body.items).to.have.length(1);
|
|
162
|
+
});
|
|
163
|
+
(0, mocha_1.it)("chunks message batches too", async () => {
|
|
164
|
+
const { rest, requests } = setupBatchRest();
|
|
165
|
+
const api = new messages_api_1.MessagesApi(rest);
|
|
166
|
+
const items = Array.from({ length: batch_1.MAX_BATCH_ITEMS + 1 }, (_, i) => ({
|
|
167
|
+
agent_id: `a${i}`,
|
|
168
|
+
channel_name: "c1",
|
|
169
|
+
message_id: `m${i}`,
|
|
170
|
+
data: {},
|
|
171
|
+
}));
|
|
172
|
+
const response = await api.batchPatchMessages(items);
|
|
173
|
+
(0, chai_1.expect)(requests).to.have.length(2);
|
|
174
|
+
(0, chai_1.expect)(response.count).to.equal(batch_1.MAX_BATCH_ITEMS + 1);
|
|
175
|
+
});
|
|
176
|
+
(0, mocha_1.it)("issues no request for an empty message batch", async () => {
|
|
177
|
+
const { rest, requests } = setupBatchRest();
|
|
178
|
+
const api = new messages_api_1.MessagesApi(rest);
|
|
179
|
+
const response = await api.batchDeleteMessages([]);
|
|
180
|
+
(0, chai_1.expect)(requests).to.have.length(0);
|
|
181
|
+
(0, chai_1.expect)(response.count).to.equal(0);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -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,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
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the bounded batch mutation endpoints.
|
|
3
|
+
*
|
|
4
|
+
* The data API exposes cross-agent batch mutations at `/agents/aggregates`
|
|
5
|
+
* and `/agents/messages`. Every item carries its own `agent_id` and
|
|
6
|
+
* `channel_name`, each is authorised independently, and the batch can
|
|
7
|
+
* partially succeed — results come back in request order and callers should
|
|
8
|
+
* retry only the failed entries.
|
|
9
|
+
*
|
|
10
|
+
* See `docs/batch-aggregate-updates.md` in channels-rest for the contract.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Server-enforced ceiling on items per batch request. Exceeding it is a 400,
|
|
14
|
+
* so callers must chunk. `chunkBatchItems` does this for you.
|
|
15
|
+
*/
|
|
16
|
+
export declare const MAX_BATCH_ITEMS = 50;
|
|
17
|
+
/** A single merge-patch against one agent's channel aggregate. */
|
|
18
|
+
export interface BatchAggregateUpdateItem {
|
|
19
|
+
agent_id: string;
|
|
20
|
+
channel_name: string;
|
|
21
|
+
/** Merge patch. `null` at a leaf clears that key. */
|
|
22
|
+
data: Record<string, unknown>;
|
|
23
|
+
/**
|
|
24
|
+
* Dot-paths to replace outright rather than merge into. Mirrors the
|
|
25
|
+
* `replace` query parameter on the single-aggregate PATCH.
|
|
26
|
+
*/
|
|
27
|
+
replace?: string[];
|
|
28
|
+
/** Skip aggregate hooks (alarms, SNS fan-out, websocket publish). */
|
|
29
|
+
suppress_hooks?: boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface BatchCreateMessageItem {
|
|
32
|
+
agent_id: string;
|
|
33
|
+
channel_name: string;
|
|
34
|
+
/**
|
|
35
|
+
* Supplying a stable ID makes create retries idempotent. Without one the
|
|
36
|
+
* server generates an ID and a lost response can duplicate the message.
|
|
37
|
+
*/
|
|
38
|
+
message_id?: string;
|
|
39
|
+
data: unknown;
|
|
40
|
+
/** Message timestamp, ms since epoch. Defaults to server receipt time. */
|
|
41
|
+
ts?: number;
|
|
42
|
+
/** Time-to-live in seconds. */
|
|
43
|
+
ttl?: number;
|
|
44
|
+
}
|
|
45
|
+
export interface BatchUpdateMessageItem {
|
|
46
|
+
agent_id: string;
|
|
47
|
+
channel_name: string;
|
|
48
|
+
message_id: string;
|
|
49
|
+
data: unknown;
|
|
50
|
+
}
|
|
51
|
+
export interface BatchDeleteMessageItem {
|
|
52
|
+
agent_id: string;
|
|
53
|
+
channel_name: string;
|
|
54
|
+
message_id: string;
|
|
55
|
+
}
|
|
56
|
+
/** Per-item outcome. `error` is present only when `success` is false. */
|
|
57
|
+
export interface BatchResultItem {
|
|
58
|
+
agent_id: string;
|
|
59
|
+
channel_name: string;
|
|
60
|
+
success: boolean;
|
|
61
|
+
error?: string;
|
|
62
|
+
}
|
|
63
|
+
export interface BatchMessageResultItem extends BatchResultItem {
|
|
64
|
+
/** Present for creates — the server-generated ID when none was supplied. */
|
|
65
|
+
message_id?: string;
|
|
66
|
+
}
|
|
67
|
+
export interface BatchResponse<TItem extends BatchResultItem = BatchResultItem> {
|
|
68
|
+
/** Results in request order, one per submitted item. */
|
|
69
|
+
items: TItem[];
|
|
70
|
+
count: number;
|
|
71
|
+
succeeded: number;
|
|
72
|
+
failed: number;
|
|
73
|
+
}
|
|
74
|
+
export type BatchAggregateResponse = BatchResponse<BatchResultItem>;
|
|
75
|
+
export type BatchMessageResponse = BatchResponse<BatchMessageResultItem>;
|
|
76
|
+
/**
|
|
77
|
+
* Split `items` into server-acceptable chunks. Returns `[]` for an empty
|
|
78
|
+
* input, since an empty batch is a 400 rather than a no-op.
|
|
79
|
+
*/
|
|
80
|
+
export declare function chunkBatchItems<T>(items: T[], size?: number): T[][];
|
|
81
|
+
/**
|
|
82
|
+
* Merge several batch responses into one, as if the whole set had been sent
|
|
83
|
+
* in a single request. Useful after chunking.
|
|
84
|
+
*/
|
|
85
|
+
export declare function mergeBatchResponses<TItem extends BatchResultItem>(responses: BatchResponse<TItem>[]): BatchResponse<TItem>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Shared types for the bounded batch mutation endpoints.
|
|
4
|
+
*
|
|
5
|
+
* The data API exposes cross-agent batch mutations at `/agents/aggregates`
|
|
6
|
+
* and `/agents/messages`. Every item carries its own `agent_id` and
|
|
7
|
+
* `channel_name`, each is authorised independently, and the batch can
|
|
8
|
+
* partially succeed — results come back in request order and callers should
|
|
9
|
+
* retry only the failed entries.
|
|
10
|
+
*
|
|
11
|
+
* See `docs/batch-aggregate-updates.md` in channels-rest for the contract.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.MAX_BATCH_ITEMS = void 0;
|
|
15
|
+
exports.chunkBatchItems = chunkBatchItems;
|
|
16
|
+
exports.mergeBatchResponses = mergeBatchResponses;
|
|
17
|
+
/**
|
|
18
|
+
* Server-enforced ceiling on items per batch request. Exceeding it is a 400,
|
|
19
|
+
* so callers must chunk. `chunkBatchItems` does this for you.
|
|
20
|
+
*/
|
|
21
|
+
exports.MAX_BATCH_ITEMS = 50;
|
|
22
|
+
/**
|
|
23
|
+
* Split `items` into server-acceptable chunks. Returns `[]` for an empty
|
|
24
|
+
* input, since an empty batch is a 400 rather than a no-op.
|
|
25
|
+
*/
|
|
26
|
+
function chunkBatchItems(items, size = exports.MAX_BATCH_ITEMS) {
|
|
27
|
+
if (size < 1)
|
|
28
|
+
throw new RangeError("chunk size must be at least 1");
|
|
29
|
+
const out = [];
|
|
30
|
+
for (let i = 0; i < items.length; i += size) {
|
|
31
|
+
out.push(items.slice(i, i + size));
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Merge several batch responses into one, as if the whole set had been sent
|
|
37
|
+
* in a single request. Useful after chunking.
|
|
38
|
+
*/
|
|
39
|
+
function mergeBatchResponses(responses) {
|
|
40
|
+
const items = responses.flatMap((r) => r.items);
|
|
41
|
+
return {
|
|
42
|
+
items,
|
|
43
|
+
count: items.length,
|
|
44
|
+
succeeded: items.filter((i) => i.success).length,
|
|
45
|
+
failed: items.filter((i) => !i.success).length,
|
|
46
|
+
};
|
|
47
|
+
}
|