doover-js 0.6.5 → 0.6.7

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.
@@ -0,0 +1,757 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OfflineDataClient = exports.MemoryOfflineStorageAdapter = exports.DEFAULT_OFFLINE_RETENTION_MS = void 0;
4
+ const errors_1 = require("./errors");
5
+ const request_options_1 = require("./request-options");
6
+ exports.DEFAULT_OFFLINE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
7
+ class MemoryOfflineStorageAdapter {
8
+ constructor() {
9
+ this.json = new Map();
10
+ this.blobs = new Map();
11
+ }
12
+ async getJson(key) {
13
+ return this.json.get(key);
14
+ }
15
+ async setJson(key, record) {
16
+ this.json.set(key, record);
17
+ }
18
+ async deleteJson(key) {
19
+ this.json.delete(key);
20
+ }
21
+ async listJsonKeys(prefix) {
22
+ const keys = [...this.json.keys()];
23
+ return prefix ? keys.filter((key) => key.startsWith(prefix)) : keys;
24
+ }
25
+ async clearScope(scope) {
26
+ for (const [key, record] of this.json) {
27
+ if (scopeMatches(record.scope, scope))
28
+ this.json.delete(key);
29
+ }
30
+ for (const [key, record] of this.blobs) {
31
+ if (scopeMatches(record.metadata.scope, scope))
32
+ this.blobs.delete(key);
33
+ }
34
+ }
35
+ async getBlob(key) {
36
+ return this.blobs.get(key)?.blob;
37
+ }
38
+ async setBlob(key, blob, metadata) {
39
+ this.blobs.set(key, { blob, metadata });
40
+ }
41
+ async deleteBlob(key) {
42
+ this.blobs.delete(key);
43
+ }
44
+ }
45
+ exports.MemoryOfflineStorageAdapter = MemoryOfflineStorageAdapter;
46
+ const SCHEMA_VERSION = 1;
47
+ const INITIAL_OFFLINE_STATUS = {
48
+ online: true,
49
+ state: "online",
50
+ isOfflineFallback: false,
51
+ isExpired: false,
52
+ at: 0,
53
+ };
54
+ class OfflineDataClient {
55
+ constructor(options) {
56
+ this.offlineStatusListeners = new Set();
57
+ this.offlineStatus = INITIAL_OFFLINE_STATUS;
58
+ this.persistQueue = Promise.resolve();
59
+ this.client = options.client;
60
+ this.storage = options.storage;
61
+ this.scope = options.scope;
62
+ this.defaultRetentionMs = options.defaultRetentionMs ?? exports.DEFAULT_OFFLINE_RETENTION_MS;
63
+ this.isOnlineFn = options.isOnline;
64
+ this.policies = [...(options.policies ?? [])];
65
+ this.agents = this.makeAgentsFacade();
66
+ this.channels = this.makeChannelsFacade();
67
+ this.messages = this.makeMessagesFacade();
68
+ this.aggregates = this.makeAggregatesFacade();
69
+ this.alarms = this.passThroughSubclient("alarms");
70
+ this.connections = this.passThroughSubclient("connections");
71
+ this.notifications = this.passThroughSubclient("notifications");
72
+ this.permissions = this.passThroughSubclient("permissions");
73
+ this.processors = this.passThroughSubclient("processors");
74
+ this.turn = this.passThroughSubclient("turn");
75
+ this.users = this.passThroughSubclient("users");
76
+ this.gateway = this.makeGatewayFacade();
77
+ this.rpc = this.makeRpcFacade();
78
+ }
79
+ setChannelPolicy(policy) {
80
+ const id = policy.id ?? `${policy.channel.agentId ?? "*"}/${policy.channel.channelName}`;
81
+ this.policies = [
82
+ ...this.policies.filter((existing) => (existing.id ?? `${existing.channel.agentId ?? "*"}/${existing.channel.channelName}`) !== id),
83
+ { ...policy, id },
84
+ ];
85
+ }
86
+ setChannelPolicies(policies) {
87
+ this.policies = [...policies];
88
+ }
89
+ getChannelPolicies() {
90
+ return this.policies;
91
+ }
92
+ getOfflineStatus() {
93
+ return {
94
+ ...this.offlineStatus,
95
+ online: this.isOnline(),
96
+ };
97
+ }
98
+ onOfflineStatusChange(listener) {
99
+ this.offlineStatusListeners.add(listener);
100
+ let active = true;
101
+ return () => {
102
+ if (!active)
103
+ return;
104
+ active = false;
105
+ this.offlineStatusListeners.delete(listener);
106
+ };
107
+ }
108
+ async clearScope(scope = this.scope) {
109
+ if (this.storage.clearScope) {
110
+ await this.storage.clearScope(scope);
111
+ return;
112
+ }
113
+ const keys = await this.storage.listJsonKeys?.(this.scopePrefix(scope));
114
+ if (!keys || !this.storage.deleteJson)
115
+ return;
116
+ await Promise.all(keys.map((key) => this.storage.deleteJson(key)));
117
+ }
118
+ getCapabilities() { return this.client.getCapabilities(); }
119
+ supports(cap) { return this.client.supports(cap); }
120
+ isConnected() { return this.client.isConnected(); }
121
+ getStatus() { return this.client.getStatus(); }
122
+ onStatusChange(listener) {
123
+ return this.client.onStatusChange(listener);
124
+ }
125
+ getAgentScope() { return this.client.getAgentScope(); }
126
+ getKnownAgentScope() { return this.client.getKnownAgentScope(); }
127
+ makeAgentsFacade() {
128
+ const target = this.client.agents;
129
+ return {
130
+ listAgents: ((...rawArgs) => {
131
+ const { args, request } = (0, request_options_1.splitRequestOptions)(rawArgs);
132
+ return this.readCached(() => target.listAgents(...this.withDelegateOptions(args, request)), {
133
+ operation: "agents.listAgents",
134
+ kind: "agents.list",
135
+ keyParts: ["agents", "list", args],
136
+ request,
137
+ });
138
+ }),
139
+ getMultiAgentMessages: ((channelName, ...rest) => {
140
+ const { args, request } = (0, request_options_1.splitRequestOptions)([channelName, ...rest]);
141
+ const params = args[1];
142
+ return this.readCached(() => target.getMultiAgentMessages(...this.withDelegateOptions(args, request)), {
143
+ operation: "agents.getMultiAgentMessages",
144
+ kind: "agents.multiAgentMessages",
145
+ keyParts: ["agents", "multiAgentMessages", args],
146
+ channelName: String(args[0]),
147
+ agentId: params?.agent_id?.join(","),
148
+ request,
149
+ });
150
+ }),
151
+ getMultiAgentAggregates: ((channelName, ...rest) => {
152
+ const { args, request } = (0, request_options_1.splitRequestOptions)([channelName, ...rest]);
153
+ const params = args[1];
154
+ return this.readCached(() => target.getMultiAgentAggregates(...this.withDelegateOptions(args, request)), {
155
+ operation: "agents.getMultiAgentAggregates",
156
+ kind: "agents.multiAgentAggregates",
157
+ keyParts: ["agents", "multiAgentAggregates", args],
158
+ channelName: String(args[0]),
159
+ agentId: params?.agent_id?.join(","),
160
+ request,
161
+ });
162
+ }),
163
+ };
164
+ }
165
+ makeChannelsFacade() {
166
+ const target = this.client.channels;
167
+ return {
168
+ listChannels: ((...rawArgs) => {
169
+ const { args, request } = (0, request_options_1.splitRequestOptions)(rawArgs);
170
+ const agentId = extractAgentId(args);
171
+ return this.readCached(() => target.listChannels(...this.withDelegateOptions(args, request)), {
172
+ operation: "channels.listChannels",
173
+ kind: "channels.list",
174
+ keyParts: ["channels", "list", args],
175
+ agentId,
176
+ request,
177
+ });
178
+ }),
179
+ getChannel: ((...rawArgs) => {
180
+ const { args, request } = (0, request_options_1.splitRequestOptions)(rawArgs);
181
+ const { agentId, channelName } = extractChannelId(args);
182
+ return this.readCached(() => target.getChannel(...this.withDelegateOptions(args, request)), {
183
+ operation: "channels.getChannel",
184
+ kind: "channels.get",
185
+ keyParts: ["channels", "get", args],
186
+ agentId,
187
+ channelName,
188
+ request,
189
+ });
190
+ }),
191
+ createChannel: this.offlineGuard("channels.createChannel", target.createChannel.bind(target)),
192
+ putChannel: this.offlineGuard("channels.putChannel", target.putChannel.bind(target)),
193
+ archiveChannel: this.offlineGuard("channels.archiveChannel", target.archiveChannel.bind(target)),
194
+ unarchiveChannel: this.offlineGuard("channels.unarchiveChannel", target.unarchiveChannel.bind(target)),
195
+ listDataSeries: ((...args) => target.listDataSeries(...args)),
196
+ };
197
+ }
198
+ makeAggregatesFacade() {
199
+ const target = this.client.aggregates;
200
+ return {
201
+ getAggregate: ((...rawArgs) => {
202
+ const { args, request } = (0, request_options_1.splitRequestOptions)(rawArgs);
203
+ const { agentId, channelName } = extractChannelId(args);
204
+ return this.readCached(() => target.getAggregate(...this.withDelegateOptions(args, request)), {
205
+ operation: "aggregates.getAggregate",
206
+ kind: "aggregates.get",
207
+ keyParts: ["aggregates", "get", args],
208
+ agentId,
209
+ channelName,
210
+ request,
211
+ serverUpdatedAt: (value) => typeof value?.last_updated === "number"
212
+ ? value.last_updated
213
+ : undefined,
214
+ });
215
+ }),
216
+ putAggregate: this.offlineGuard("aggregates.putAggregate", target.putAggregate.bind(target)),
217
+ patchAggregate: this.offlineGuard("aggregates.patchAggregate", target.patchAggregate.bind(target)),
218
+ getAggregateAttachment: ((...rawArgs) => {
219
+ const { args, request } = (0, request_options_1.splitRequestOptions)(rawArgs);
220
+ const { agentId, channelName } = extractChannelId(args);
221
+ const attachmentId = String(args[2] ?? args[1] ?? "");
222
+ return this.readBlobCached(() => target.getAggregateAttachment(...this.withDelegateOptions(args, request)), {
223
+ operation: "aggregates.getAggregateAttachment",
224
+ kind: "aggregates.attachment",
225
+ keyParts: ["aggregates", "attachment", args, attachmentId],
226
+ agentId,
227
+ channelName,
228
+ request,
229
+ });
230
+ }),
231
+ };
232
+ }
233
+ makeMessagesFacade() {
234
+ const target = this.client.messages;
235
+ return {
236
+ listMessages: ((...rawArgs) => {
237
+ const { args, request } = (0, request_options_1.splitRequestOptions)(rawArgs);
238
+ const { agentId, channelName } = extractChannelId(args);
239
+ return this.readCached(() => target.listMessages(...this.withDelegateOptions(args, request)), {
240
+ operation: "messages.listMessages",
241
+ kind: "messages.list",
242
+ keyParts: ["messages", "list", args],
243
+ fallbackKeyParts: this.latestMessagesKeyParts(agentId ?? "", channelName ?? ""),
244
+ agentId,
245
+ channelName,
246
+ request,
247
+ serverUpdatedAt: newestMessageTimestamp,
248
+ });
249
+ }),
250
+ getMessage: ((...rawArgs) => {
251
+ const { args, request } = (0, request_options_1.splitRequestOptions)(rawArgs);
252
+ const { agentId, channelName } = extractChannelId(args);
253
+ const messageId = String(args[2] ?? args[1] ?? "");
254
+ return this.readCached(() => target.getMessage(...this.withDelegateOptions(args, request)), {
255
+ operation: "messages.getMessage",
256
+ kind: "messages.get",
257
+ keyParts: ["messages", "get", args, messageId],
258
+ agentId,
259
+ channelName,
260
+ request,
261
+ serverUpdatedAt: (value) => typeof value?.timestamp === "number"
262
+ ? value.timestamp
263
+ : undefined,
264
+ });
265
+ }),
266
+ postMessage: this.offlineGuard("messages.postMessage", target.postMessage.bind(target)),
267
+ putMessage: this.offlineGuard("messages.putMessage", target.putMessage.bind(target)),
268
+ patchMessage: this.offlineGuard("messages.patchMessage", target.patchMessage.bind(target)),
269
+ deleteMessage: this.offlineGuard("messages.deleteMessage", target.deleteMessage.bind(target)),
270
+ getTimeseries: ((...args) => target.getTimeseries(...args)),
271
+ getMessageAttachment: ((...rawArgs) => {
272
+ const { args, request } = (0, request_options_1.splitRequestOptions)(rawArgs);
273
+ const { agentId, channelName } = extractChannelId(args);
274
+ const messageId = String(args[2] ?? args[1] ?? "");
275
+ const attachmentId = String(args[3] ?? args[2] ?? "");
276
+ return this.readBlobCached(() => target.getMessageAttachment(...this.withDelegateOptions(args, request)), {
277
+ operation: "messages.getMessageAttachment",
278
+ kind: "messages.attachment",
279
+ keyParts: ["messages", "attachment", args, messageId, attachmentId],
280
+ agentId,
281
+ channelName,
282
+ request,
283
+ });
284
+ }),
285
+ getInvocationLogs: ((...args) => target.getInvocationLogs(...args)),
286
+ createMultipartPayload: target.createMultipartPayload.bind(target),
287
+ };
288
+ }
289
+ makeGatewayFacade() {
290
+ const target = this.client.gateway;
291
+ return {
292
+ ...target,
293
+ connect: target.connect.bind(target),
294
+ disconnect: target.disconnect.bind(target),
295
+ on: target.on.bind(target),
296
+ off: target.off.bind(target),
297
+ subscribe: target.subscribe.bind(target),
298
+ unsubscribe: target.unsubscribe.bind(target),
299
+ subscribeToChannel: ((channel, handlers) => target.subscribeToChannel(channel, {
300
+ ...handlers,
301
+ onMessage: (message) => {
302
+ this.enqueuePersist(() => this.persistGatewayMessage(message));
303
+ handlers.onMessage?.(message);
304
+ },
305
+ onMessageUpdate: (message, requestData) => {
306
+ this.enqueuePersist(() => this.persistGatewayMessage(message));
307
+ handlers.onMessageUpdate?.(message, requestData);
308
+ },
309
+ onAggregate: (aggregate) => {
310
+ this.enqueuePersist(() => this.persistGatewayAggregate(channel, aggregate));
311
+ handlers.onAggregate?.(aggregate);
312
+ },
313
+ })),
314
+ syncChannel: this.offlineGuard("gateway.syncChannel", target.syncChannel.bind(target)),
315
+ sendOneShotMessage: this.offlineGuard("gateway.sendOneShotMessage", target.sendOneShotMessage.bind(target)),
316
+ getSession: target.getSession.bind(target),
317
+ isConnected: target.isConnected.bind(target),
318
+ getSubscriptionCount: target.getSubscriptionCount.bind(target),
319
+ getSubscriptions: target.getSubscriptions.bind(target),
320
+ reconnect: target.reconnect.bind(target),
321
+ };
322
+ }
323
+ makeRpcFacade() {
324
+ const target = this.client.rpc;
325
+ return {
326
+ setStats: target.setStats?.bind(target) ?? (() => undefined),
327
+ send: this.offlineGuard("rpc.send", target.send.bind(target)),
328
+ };
329
+ }
330
+ passThroughSubclient(name) {
331
+ const target = this.client[name];
332
+ return new Proxy({}, {
333
+ get: (_unused, prop) => {
334
+ if (typeof prop !== "string")
335
+ return undefined;
336
+ const value = target[prop];
337
+ if (typeof value !== "function")
338
+ return value;
339
+ const operation = `${name}.${prop}`;
340
+ if (isLikelyWrite(prop))
341
+ return this.offlineGuard(operation, value.bind(target));
342
+ return value.bind(target);
343
+ },
344
+ });
345
+ }
346
+ offlineGuard(operation, fn) {
347
+ return ((...args) => {
348
+ if (!this.isOnline())
349
+ throw new errors_1.DooverOfflineError(operation);
350
+ return fn(...args);
351
+ });
352
+ }
353
+ async readCached(fetcher, descriptor) {
354
+ const effective = this.effectivePolicy(descriptor);
355
+ const key = this.cacheKey(descriptor);
356
+ const fallbackKey = descriptor.fallbackKeyParts
357
+ ? this.cacheKey({ ...descriptor, kind: "messages.latest", keyParts: descriptor.fallbackKeyParts })
358
+ : undefined;
359
+ const read = () => this.readRecord(key, descriptor.operation, effective.allowExpired, fallbackKey);
360
+ if (effective.mode === "cache-only" || !this.isOnline()) {
361
+ return read();
362
+ }
363
+ if (effective.mode === "cache-first") {
364
+ const cached = await this.tryReadRecord(key, effective.allowExpired);
365
+ if (cached.hit)
366
+ return cached.value;
367
+ }
368
+ try {
369
+ const value = await fetcher();
370
+ if (effective.enabled) {
371
+ await this.writeRecord(key, descriptor, effective, value);
372
+ await this.writePolicyDerivedRecords(descriptor, effective, value);
373
+ }
374
+ this.setOfflineStatus({
375
+ state: "online",
376
+ operation: descriptor.operation,
377
+ cacheKey: key,
378
+ isOfflineFallback: false,
379
+ isExpired: false,
380
+ });
381
+ return value;
382
+ }
383
+ catch (error) {
384
+ if (!effective.enabled)
385
+ throw error;
386
+ const cached = await this.tryReadRecord(key, effective.allowExpired);
387
+ if (cached.hit)
388
+ return cached.value;
389
+ throw error;
390
+ }
391
+ }
392
+ async readBlobCached(fetcher, descriptor) {
393
+ const effective = this.effectivePolicy(descriptor);
394
+ const key = this.cacheKey(descriptor);
395
+ if (effective.mode === "cache-only" || !this.isOnline()) {
396
+ const cached = await this.storage.getBlob?.(key);
397
+ if (cached) {
398
+ this.setOfflineStatus({
399
+ state: this.isOnline() ? "cache-hit" : "cache-fallback",
400
+ operation: descriptor.operation,
401
+ cacheKey: key,
402
+ isOfflineFallback: !this.isOnline(),
403
+ isExpired: false,
404
+ });
405
+ return cached;
406
+ }
407
+ this.setOfflineStatus({
408
+ state: "cache-miss",
409
+ operation: descriptor.operation,
410
+ cacheKey: key,
411
+ isOfflineFallback: !this.isOnline(),
412
+ isExpired: false,
413
+ });
414
+ throw new errors_1.DooverOfflineError(descriptor.operation, `No cached attachment is available for "${descriptor.operation}" while offline.`, key);
415
+ }
416
+ if (effective.mode === "cache-first") {
417
+ const cached = await this.storage.getBlob?.(key);
418
+ if (cached)
419
+ return cached;
420
+ }
421
+ const value = await fetcher();
422
+ if (!(value instanceof Blob))
423
+ return value;
424
+ if (effective.enabled && this.shouldCacheAttachmentBlob(descriptor)) {
425
+ const now = Date.now();
426
+ await this.storage.setBlob?.(key, value, {
427
+ key,
428
+ scope: this.scope,
429
+ kind: descriptor.kind,
430
+ value: {
431
+ content_type: value.type || null,
432
+ size: value.size,
433
+ },
434
+ cachedAt: now,
435
+ lastAccessedAt: now,
436
+ expiresAt: effective.retentionMs > 0 ? now + effective.retentionMs : null,
437
+ schemaVersion: SCHEMA_VERSION,
438
+ ...(effective.policyId ? { policyId: effective.policyId } : {}),
439
+ });
440
+ }
441
+ this.setOfflineStatus({
442
+ state: "online",
443
+ operation: descriptor.operation,
444
+ cacheKey: key,
445
+ isOfflineFallback: false,
446
+ isExpired: false,
447
+ });
448
+ return value;
449
+ }
450
+ async readRecord(key, operation, allowExpired, fallbackKey) {
451
+ const result = await this.tryReadRecord(key, allowExpired);
452
+ if (result.hit)
453
+ return result.value;
454
+ if (fallbackKey) {
455
+ const fallback = await this.tryReadRecord(fallbackKey, allowExpired);
456
+ if (fallback.hit)
457
+ return fallback.value;
458
+ }
459
+ this.setOfflineStatus({
460
+ state: "cache-miss",
461
+ operation,
462
+ cacheKey: key,
463
+ isOfflineFallback: !this.isOnline(),
464
+ isExpired: false,
465
+ });
466
+ throw new errors_1.DooverOfflineError(operation, `No cached data is available for "${operation}" while offline.`, key);
467
+ }
468
+ async tryReadRecord(key, allowExpired) {
469
+ const record = await this.storage.getJson(key);
470
+ if (!record)
471
+ return { hit: false };
472
+ const expired = record.expiresAt !== null && record.expiresAt <= Date.now();
473
+ if (expired && !allowExpired) {
474
+ this.setOfflineStatus({
475
+ state: "expired",
476
+ operation: record.kind,
477
+ cacheKey: key,
478
+ cachedAt: record.cachedAt,
479
+ expiresAt: record.expiresAt,
480
+ serverUpdatedAt: record.serverUpdatedAt ?? null,
481
+ isOfflineFallback: !this.isOnline(),
482
+ isExpired: true,
483
+ });
484
+ return { hit: false };
485
+ }
486
+ record.lastAccessedAt = Date.now();
487
+ await this.storage.setJson(key, record);
488
+ this.setOfflineStatus({
489
+ state: this.isOnline() ? "cache-hit" : "cache-fallback",
490
+ operation: record.kind,
491
+ cacheKey: key,
492
+ cachedAt: record.cachedAt,
493
+ expiresAt: record.expiresAt,
494
+ serverUpdatedAt: record.serverUpdatedAt ?? null,
495
+ isOfflineFallback: !this.isOnline(),
496
+ isExpired: expired,
497
+ });
498
+ return { hit: true, value: record.value };
499
+ }
500
+ async writeRecord(key, descriptor, effective, value) {
501
+ const now = Date.now();
502
+ const record = {
503
+ key,
504
+ scope: this.scope,
505
+ kind: descriptor.kind,
506
+ value,
507
+ cachedAt: now,
508
+ lastAccessedAt: now,
509
+ expiresAt: effective.retentionMs > 0 ? now + effective.retentionMs : null,
510
+ serverUpdatedAt: descriptor.serverUpdatedAt?.(value) ?? null,
511
+ schemaVersion: SCHEMA_VERSION,
512
+ ...(effective.policyId ? { policyId: effective.policyId } : {}),
513
+ };
514
+ await this.storage.setJson(key, record);
515
+ }
516
+ async writePolicyDerivedRecords(descriptor, effective, value) {
517
+ if (descriptor.kind !== "messages.list" || !descriptor.agentId || !descriptor.channelName) {
518
+ return;
519
+ }
520
+ if (!Array.isArray(value))
521
+ return;
522
+ await this.writeLatestMessagesRecord(descriptor.agentId, descriptor.channelName, value, effective);
523
+ }
524
+ async writeLatestMessagesRecord(agentId, channelName, messages, effective) {
525
+ const policy = this.matchPolicy(agentId, channelName);
526
+ if (!policy && !effective)
527
+ return;
528
+ if (policy?.cache === false)
529
+ return;
530
+ const retentionMs = effective?.retentionMs ?? policy?.retentionMs ?? this.defaultRetentionMs;
531
+ const count = policy?.messages?.count ?? messages.length;
532
+ const pruned = pruneLatestMessages(messages, count);
533
+ const descriptor = {
534
+ operation: "messages.latest",
535
+ kind: "messages.latest",
536
+ keyParts: this.latestMessagesKeyParts(agentId, channelName),
537
+ agentId,
538
+ channelName,
539
+ serverUpdatedAt: newestMessageTimestamp,
540
+ };
541
+ await this.writeRecord(this.cacheKey(descriptor), descriptor, {
542
+ enabled: true,
543
+ mode: "network-first",
544
+ retentionMs,
545
+ allowExpired: false,
546
+ ...(effective?.policyId || policy?.id ? { policyId: effective?.policyId ?? policy?.id } : {}),
547
+ }, pruned);
548
+ }
549
+ async mergeLatestMessage(agentId, channelName, message) {
550
+ const policy = this.matchPolicy(agentId, channelName);
551
+ if (!policy || policy.cache === false)
552
+ return;
553
+ const descriptor = {
554
+ operation: "messages.latest",
555
+ kind: "messages.latest",
556
+ keyParts: this.latestMessagesKeyParts(agentId, channelName),
557
+ agentId,
558
+ channelName,
559
+ serverUpdatedAt: newestMessageTimestamp,
560
+ };
561
+ const key = this.cacheKey(descriptor);
562
+ const existing = await this.storage.getJson(key);
563
+ const current = existing?.value ?? [];
564
+ const withoutDuplicate = current.filter((item) => item.id !== message.id);
565
+ await this.writeLatestMessagesRecord(agentId, channelName, [...withoutDuplicate, message], {
566
+ enabled: true,
567
+ mode: "network-first",
568
+ retentionMs: policy.retentionMs ?? this.defaultRetentionMs,
569
+ allowExpired: false,
570
+ ...(policy.id ? { policyId: policy.id } : {}),
571
+ });
572
+ }
573
+ async persistGatewayMessage(message) {
574
+ await this.mergeLatestMessage(message.channel.agent_id, message.channel.name, message);
575
+ const descriptor = {
576
+ operation: "messages.getMessage",
577
+ kind: "messages.get",
578
+ keyParts: ["messages", "get", [{ agentId: message.channel.agent_id, channelName: message.channel.name }, message.id], message.id],
579
+ agentId: message.channel.agent_id,
580
+ channelName: message.channel.name,
581
+ serverUpdatedAt: (value) => typeof value?.timestamp === "number"
582
+ ? value.timestamp
583
+ : undefined,
584
+ };
585
+ const policy = this.matchPolicy(message.channel.agent_id, message.channel.name);
586
+ if (!policy || policy.cache === false)
587
+ return;
588
+ await this.writeRecord(this.cacheKey(descriptor), descriptor, {
589
+ enabled: true,
590
+ mode: "network-first",
591
+ retentionMs: policy.retentionMs ?? this.defaultRetentionMs,
592
+ allowExpired: false,
593
+ ...(policy.id ? { policyId: policy.id } : {}),
594
+ }, message);
595
+ }
596
+ async persistGatewayAggregate(channel, aggregate) {
597
+ const policy = this.matchPolicy(channel.agent_id, channel.name);
598
+ if (!policy || policy.cache === false)
599
+ return;
600
+ const descriptor = {
601
+ operation: "aggregates.getAggregate",
602
+ kind: "aggregates.get",
603
+ keyParts: ["aggregates", "get", [{ agentId: channel.agent_id, channelName: channel.name }]],
604
+ agentId: channel.agent_id,
605
+ channelName: channel.name,
606
+ serverUpdatedAt: (value) => typeof value?.last_updated === "number"
607
+ ? value.last_updated
608
+ : undefined,
609
+ };
610
+ await this.writeRecord(this.cacheKey(descriptor), descriptor, {
611
+ enabled: true,
612
+ mode: "network-first",
613
+ retentionMs: policy.retentionMs ?? this.defaultRetentionMs,
614
+ allowExpired: false,
615
+ ...(policy.id ? { policyId: policy.id } : {}),
616
+ }, aggregate);
617
+ }
618
+ effectivePolicy(descriptor) {
619
+ const cache = descriptor.request?.cache;
620
+ const cacheOptions = cache === false ? undefined : cache;
621
+ const matching = this.matchPolicy(descriptor.agentId, descriptor.channelName);
622
+ const enabled = cache === false ? false :
623
+ cacheOptions?.enabled !== undefined ? cacheOptions.enabled :
624
+ matching ? matching.cache !== false :
625
+ cache !== undefined;
626
+ return {
627
+ enabled,
628
+ mode: cacheOptions?.mode ?? "network-first",
629
+ retentionMs: cacheOptions?.retentionMs !== undefined
630
+ ? cacheOptions.retentionMs
631
+ : matching?.retentionMs ?? this.defaultRetentionMs,
632
+ allowExpired: !!cacheOptions?.allowExpired,
633
+ ...(cacheOptions?.policy ? { policyId: cacheOptions.policy } : matching?.id ? { policyId: matching.id } : {}),
634
+ };
635
+ }
636
+ matchPolicy(agentId, channelName) {
637
+ if (!channelName)
638
+ return undefined;
639
+ return this.policies.find((policy) => policy.channel.channelName === channelName &&
640
+ (policy.channel.agentId === undefined || policy.channel.agentId === agentId));
641
+ }
642
+ shouldCacheAttachmentBlob(descriptor) {
643
+ const policy = this.matchPolicy(descriptor.agentId, descriptor.channelName);
644
+ if (!policy)
645
+ return descriptor.request?.cache !== undefined && descriptor.request.cache !== false;
646
+ if (descriptor.kind === "messages.attachment") {
647
+ return policy.messageAttachments?.include === "metadata-and-selected-blobs";
648
+ }
649
+ if (descriptor.kind === "aggregates.attachment") {
650
+ return policy.aggregateAttachments?.include === "metadata-and-selected-blobs";
651
+ }
652
+ return false;
653
+ }
654
+ withDelegateOptions(args, request) {
655
+ const delegate = (0, request_options_1.delegateRequestOptions)(request);
656
+ return delegate ? [...args, delegate] : args;
657
+ }
658
+ cacheKey(descriptor) {
659
+ return `${this.scopePrefix(this.scope)}:${stableStringify({
660
+ kind: descriptor.kind,
661
+ keyParts: descriptor.keyParts,
662
+ sources: descriptor.request?.sources ? [...descriptor.request.sources].sort() : undefined,
663
+ })}`;
664
+ }
665
+ latestMessagesKeyParts(agentId, channelName) {
666
+ return ["messages", "latest", { agentId, channelName }];
667
+ }
668
+ scopePrefix(scope) {
669
+ return `doover-offline:v${SCHEMA_VERSION}:${stableStringify({
670
+ userId: scope.userId,
671
+ organisationId: scope.organisationId ?? null,
672
+ sourceId: scope.sourceId ?? "default",
673
+ sharing: scope.sharing ?? null,
674
+ assumeUserId: scope.assumeUserId ?? null,
675
+ })}`;
676
+ }
677
+ isOnline() {
678
+ if (this.isOnlineFn)
679
+ return this.isOnlineFn();
680
+ const nav = globalThis.navigator;
681
+ return nav?.onLine ?? true;
682
+ }
683
+ setOfflineStatus(status) {
684
+ this.offlineStatus = {
685
+ ...status,
686
+ online: this.isOnline(),
687
+ at: Date.now(),
688
+ };
689
+ this.offlineStatusListeners.forEach((listener) => listener(this.offlineStatus));
690
+ }
691
+ enqueuePersist(task) {
692
+ this.persistQueue = this.persistQueue
693
+ .then(task)
694
+ .catch(() => undefined);
695
+ }
696
+ }
697
+ exports.OfflineDataClient = OfflineDataClient;
698
+ function extractAgentId(args) {
699
+ return typeof args[0] === "string"
700
+ ? args[0]
701
+ : args[0]?.agentId;
702
+ }
703
+ function extractChannelId(args) {
704
+ if (typeof args[0] === "string") {
705
+ return { agentId: args[0], channelName: args[1] };
706
+ }
707
+ const id = args[0];
708
+ return { agentId: id?.agentId, channelName: id?.channelName };
709
+ }
710
+ function newestMessageTimestamp(value) {
711
+ if (!Array.isArray(value))
712
+ return undefined;
713
+ let newest = null;
714
+ for (const message of value) {
715
+ const timestamp = message.timestamp;
716
+ if (typeof timestamp === "number" && (newest === null || timestamp > newest)) {
717
+ newest = timestamp;
718
+ }
719
+ }
720
+ return newest;
721
+ }
722
+ function pruneLatestMessages(messages, count) {
723
+ if (count <= 0)
724
+ return [];
725
+ const byId = new Map();
726
+ for (const message of messages)
727
+ byId.set(message.id, message);
728
+ return [...byId.values()]
729
+ .sort((a, b) => a.timestamp - b.timestamp)
730
+ .slice(-count);
731
+ }
732
+ function isLikelyWrite(method) {
733
+ return /^(create|put|patch|post|delete|update|sync|archive|unarchive|test|regenerate|invoke)/.test(method);
734
+ }
735
+ function stableStringify(value) {
736
+ return JSON.stringify(sortForStringify(value));
737
+ }
738
+ function sortForStringify(value) {
739
+ if (Array.isArray(value))
740
+ return value.map(sortForStringify);
741
+ if (!value || typeof value !== "object")
742
+ return value;
743
+ const record = value;
744
+ return Object.keys(record).sort().reduce((acc, key) => {
745
+ const inner = record[key];
746
+ if (inner !== undefined)
747
+ acc[key] = sortForStringify(inner);
748
+ return acc;
749
+ }, {});
750
+ }
751
+ function scopeMatches(candidate, wanted) {
752
+ return (candidate.userId === wanted.userId &&
753
+ (wanted.organisationId === undefined || candidate.organisationId === wanted.organisationId) &&
754
+ (wanted.sourceId === undefined || candidate.sourceId === wanted.sourceId) &&
755
+ (wanted.sharing === undefined || candidate.sharing === wanted.sharing) &&
756
+ (wanted.assumeUserId === undefined || candidate.assumeUserId === wanted.assumeUserId));
757
+ }