@pmate/chat-sdk 0.12.0-next.8 → 0.12.0-next.81
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/README.md +170 -83
- package/lib/advanced.d.ts +10 -0
- package/lib/advanced.js +10 -0
- package/lib/chat.d.ts +5 -0
- package/lib/chat.js +11 -0
- package/lib/chatContract.d.ts +151 -0
- package/lib/chatContract.js +1 -0
- package/lib/chatError.d.ts +14 -0
- package/lib/chatError.js +16 -0
- package/lib/chatTypes.d.ts +127 -0
- package/lib/chatTypes.js +1 -0
- package/lib/commands.d.ts +20 -0
- package/lib/commands.js +12 -0
- package/lib/components/ChatInputDesktop.js +5 -4
- package/lib/components/ChatInputMobile.js +5 -4
- package/lib/components/CommandMenu.d.ts +15 -0
- package/lib/components/CommandMenu.js +10 -0
- package/lib/credentials.d.ts +26 -0
- package/lib/credentials.js +110 -0
- package/lib/fileMetadata.d.ts +9 -0
- package/lib/fileMetadata.js +18 -0
- package/lib/framework.d.ts +11 -0
- package/lib/framework.js +12 -0
- package/lib/history.d.ts +4 -0
- package/lib/history.js +8 -0
- package/lib/hooks/useChatSnapshot.d.ts +4 -0
- package/lib/hooks/useChatSnapshot.js +9 -0
- package/lib/hooks/useComposerSuggestions.d.ts +50 -0
- package/lib/hooks/useComposerSuggestions.js +70 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.js +5 -0
- package/lib/internal/chatEngine.d.ts +130 -0
- package/lib/internal/chatEngine.js +284 -0
- package/lib/internal/chatRegistry.d.ts +4 -0
- package/lib/internal/chatRegistry.js +4 -0
- package/lib/internal/createPublicChat.d.ts +4 -0
- package/lib/internal/createPublicChat.js +52 -0
- package/lib/messageView.d.ts +6 -0
- package/lib/messageView.js +103 -0
- package/lib/runtime/batchedReadIndex.d.ts +16 -3
- package/lib/runtime/batchedReadIndex.js +50 -4
- package/lib/runtime/cachedMessage.d.ts +12 -0
- package/lib/runtime/cachedMessage.js +18 -0
- package/lib/runtime/chatClient.d.ts +7 -0
- package/lib/runtime/chatClient.js +14 -3
- package/lib/runtime/chatError.d.ts +5 -0
- package/lib/runtime/chatError.js +20 -0
- package/lib/runtime/chatSession.d.ts +128 -13
- package/lib/runtime/chatSession.js +759 -48
- package/lib/runtime/connectivity.d.ts +4 -0
- package/lib/runtime/connectivity.js +8 -0
- package/lib/runtime/conversationDirectory.d.ts +37 -0
- package/lib/runtime/conversationDirectory.js +116 -0
- package/lib/runtime/conversationRead.d.ts +13 -0
- package/lib/runtime/conversationRead.js +34 -0
- package/lib/runtime/conversationReading.d.ts +28 -0
- package/lib/runtime/conversationReading.js +47 -0
- package/lib/runtime/createChat.d.ts +11 -7
- package/lib/runtime/createChat.js +46 -23
- package/lib/runtime/durableSender.d.ts +97 -14
- package/lib/runtime/durableSender.js +786 -71
- package/lib/runtime/groupClient.d.ts +91 -0
- package/lib/runtime/groupClient.js +380 -0
- package/lib/runtime/groupDenial.d.ts +2 -0
- package/lib/runtime/groupDenial.js +4 -0
- package/lib/runtime/index.d.ts +7 -0
- package/lib/runtime/index.js +5 -0
- package/lib/runtime/indexedDbRecovery.d.ts +4 -0
- package/lib/runtime/indexedDbRecovery.js +27 -0
- package/lib/runtime/messageClient.d.ts +35 -8
- package/lib/runtime/messageClient.js +364 -66
- package/lib/runtime/messageControls.d.ts +19 -0
- package/lib/runtime/messageControls.js +44 -0
- package/lib/runtime/messageLaunch.d.ts +44 -0
- package/lib/runtime/messageLaunch.js +137 -0
- package/lib/runtime/messageQuotes.d.ts +3 -0
- package/lib/runtime/messageQuotes.js +17 -0
- package/lib/runtime/messageReaders.d.ts +5 -1
- package/lib/runtime/messageReaders.js +16 -5
- package/lib/runtime/messageStore.d.ts +43 -2
- package/lib/runtime/messageStore.js +248 -14
- package/lib/runtime/messageTimeline.d.ts +50 -0
- package/lib/runtime/messageTimeline.js +120 -0
- package/lib/runtime/outboxSchema.d.ts +5 -0
- package/lib/runtime/outboxSchema.js +59 -0
- package/lib/runtime/outgoingContent.d.ts +94 -0
- package/lib/runtime/outgoingContent.js +118 -0
- package/lib/runtime/readBatchJournal.d.ts +35 -0
- package/lib/runtime/readBatchJournal.js +210 -0
- package/lib/runtime/readCounts.d.ts +15 -1
- package/lib/runtime/readCounts.js +97 -9
- package/lib/runtime/readIndex.d.ts +68 -7
- package/lib/runtime/readIndex.js +460 -22
- package/lib/runtime/readSchema.d.ts +2 -0
- package/lib/runtime/readSchema.js +58 -0
- package/lib/runtime/readSync.d.ts +1 -0
- package/lib/runtime/readSync.js +26 -9
- package/lib/runtime/readingClient.d.ts +19 -0
- package/lib/runtime/readingClient.js +131 -19
- package/lib/runtime/readingContent.d.ts +5 -0
- package/lib/runtime/readingContent.js +23 -0
- package/lib/runtime/responseCache.d.ts +40 -5
- package/lib/runtime/responseCache.js +182 -44
- package/lib/runtime/socialReadClient.d.ts +9 -4
- package/lib/runtime/socialReadClient.js +12 -8
- package/lib/runtime/voiceUpload.d.ts +2 -0
- package/lib/runtime/voiceUpload.js +27 -0
- package/lib/snapshot.d.ts +36 -0
- package/lib/snapshot.js +52 -0
- package/lib/types/chatInput.d.ts +3 -0
- package/package.json +32 -2
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { type ResponseCacheAccess } from "./responseCache.js";
|
|
2
|
+
import { type MessageScope } from "./messageStore.js";
|
|
3
|
+
import { type ChatConversation } from "./outgoingContent.js";
|
|
4
|
+
import type { SocialGroup } from "./socialReadClient.js";
|
|
5
|
+
export type ActiveChatGroup = {
|
|
6
|
+
groupId: string;
|
|
7
|
+
teamId: string;
|
|
8
|
+
ownerId: string;
|
|
9
|
+
title: string;
|
|
10
|
+
avatar?: string;
|
|
11
|
+
memberIds: readonly string[];
|
|
12
|
+
revision: string;
|
|
13
|
+
versionNumber: number;
|
|
14
|
+
status: "active";
|
|
15
|
+
};
|
|
16
|
+
export type DissolvedChatGroup = {
|
|
17
|
+
groupId: string;
|
|
18
|
+
teamId: string;
|
|
19
|
+
ownerId: string;
|
|
20
|
+
revision: string;
|
|
21
|
+
versionNumber: number;
|
|
22
|
+
status: "dissolved";
|
|
23
|
+
dissolvedBy?: string;
|
|
24
|
+
dissolvedAt?: number;
|
|
25
|
+
};
|
|
26
|
+
export type ChatGroup = ActiveChatGroup | DissolvedChatGroup;
|
|
27
|
+
export type GroupSnapshot = {
|
|
28
|
+
data?: ChatGroup;
|
|
29
|
+
loading: boolean;
|
|
30
|
+
error?: unknown;
|
|
31
|
+
};
|
|
32
|
+
export type GroupClientOptions = {
|
|
33
|
+
scope: MessageScope;
|
|
34
|
+
access: ResponseCacheAccess;
|
|
35
|
+
load: (groupId: string, signal: AbortSignal) => Promise<SocialGroup>;
|
|
36
|
+
onError: (error: unknown) => void;
|
|
37
|
+
onDenied?: (error: unknown, threadId: string, signal?: AbortSignal) => Promise<boolean | void> | boolean | void;
|
|
38
|
+
indexedDB?: IDBFactory;
|
|
39
|
+
name?: string;
|
|
40
|
+
};
|
|
41
|
+
/** SDK-owned roster cache. Read sources, concurrency and reconciliation stay private. */
|
|
42
|
+
export declare class GroupClient {
|
|
43
|
+
private options;
|
|
44
|
+
private cache;
|
|
45
|
+
private entries;
|
|
46
|
+
private pins;
|
|
47
|
+
private listeners;
|
|
48
|
+
private localQueue;
|
|
49
|
+
private localScheduled;
|
|
50
|
+
private localRunning;
|
|
51
|
+
private tasks;
|
|
52
|
+
private authority?;
|
|
53
|
+
private version?;
|
|
54
|
+
private clock;
|
|
55
|
+
private active;
|
|
56
|
+
private queue;
|
|
57
|
+
private controllers;
|
|
58
|
+
private channel?;
|
|
59
|
+
private denied;
|
|
60
|
+
private withdrawn;
|
|
61
|
+
private closed;
|
|
62
|
+
private closing?;
|
|
63
|
+
constructor(options: GroupClientOptions);
|
|
64
|
+
private assertOpen;
|
|
65
|
+
private checkAuthority;
|
|
66
|
+
private entry;
|
|
67
|
+
snapshot(threadId: string): GroupSnapshot;
|
|
68
|
+
subscribe(listener: (threadId: string) => void): () => void;
|
|
69
|
+
pin(threadId: string): () => void;
|
|
70
|
+
private publish;
|
|
71
|
+
private normalize;
|
|
72
|
+
private cached;
|
|
73
|
+
private local;
|
|
74
|
+
private flushLocal;
|
|
75
|
+
private reload;
|
|
76
|
+
get(thread: ChatConversation, options?: {
|
|
77
|
+
refresh?: boolean;
|
|
78
|
+
}): Promise<ChatGroup>;
|
|
79
|
+
private schedule;
|
|
80
|
+
private denyGroup;
|
|
81
|
+
revoke(threadId: string): Promise<void>;
|
|
82
|
+
wake(): void;
|
|
83
|
+
hint(thread: ChatConversation, versionNumber?: number): void;
|
|
84
|
+
private slot;
|
|
85
|
+
private drain;
|
|
86
|
+
private request;
|
|
87
|
+
after(thread: ChatConversation, minimumVersion: number): Promise<ChatGroup>;
|
|
88
|
+
withdraw(): void;
|
|
89
|
+
retire(version: number): Promise<void>;
|
|
90
|
+
close(): Promise<void>;
|
|
91
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { scopedGroupDenial } from "./groupDenial.js";
|
|
2
|
+
import { IndexedDbResponseCache } from "./responseCache.js";
|
|
3
|
+
import { WindowedTasks } from "./coalescer.js";
|
|
4
|
+
import { isOffline } from "./connectivity.js";
|
|
5
|
+
import { messageScopeKey } from "./messageStore.js";
|
|
6
|
+
import { validateConversation, validMessageIdentifier } from "./outgoingContent.js";
|
|
7
|
+
const empty = Object.freeze({ loading: false });
|
|
8
|
+
const token = (value) => value.status === "access-denied" ? `denied:${value.revision}` : `${value.versionNumber}:${value.revision}`;
|
|
9
|
+
/** SDK-owned roster cache. Read sources, concurrency and reconciliation stay private. */
|
|
10
|
+
export class GroupClient {
|
|
11
|
+
options;
|
|
12
|
+
cache;
|
|
13
|
+
entries = new Map();
|
|
14
|
+
pins = new Map();
|
|
15
|
+
listeners = new Set();
|
|
16
|
+
localQueue = [];
|
|
17
|
+
localScheduled = false;
|
|
18
|
+
localRunning = new Set();
|
|
19
|
+
tasks;
|
|
20
|
+
authority;
|
|
21
|
+
version;
|
|
22
|
+
clock = 0;
|
|
23
|
+
active = 0;
|
|
24
|
+
queue = [];
|
|
25
|
+
controllers = new Set();
|
|
26
|
+
channel;
|
|
27
|
+
denied = new Set();
|
|
28
|
+
withdrawn = false;
|
|
29
|
+
closed = false;
|
|
30
|
+
closing;
|
|
31
|
+
constructor(options) {
|
|
32
|
+
this.options = options;
|
|
33
|
+
const scope = JSON.stringify(["groups", messageScopeKey(options.scope)]);
|
|
34
|
+
this.options = { ...options, scope: { ...options.scope } };
|
|
35
|
+
this.cache = new IndexedDbResponseCache(scope, { indexedDB: options.indexedDB, name: options.name, access: () => this.checkAuthority(), entries: 64, batchRecords: 32, recordBytes: 131072, revision: token });
|
|
36
|
+
this.tasks = new WindowedTasks(200, error => { if (!this.closed)
|
|
37
|
+
options.onError(error); });
|
|
38
|
+
if (typeof BroadcastChannel !== "undefined") {
|
|
39
|
+
this.channel = new BroadcastChannel(`pmate-chat-groups:${scope}:${options.name ?? "default"}`);
|
|
40
|
+
this.channel.onmessage = event => {
|
|
41
|
+
if (event.data?.authority !== this.version || typeof event.data?.threadId !== "string")
|
|
42
|
+
return;
|
|
43
|
+
const entry = this.entries.get(event.data.threadId);
|
|
44
|
+
if (entry)
|
|
45
|
+
void this.reload(entry).catch(error => { if (!this.closed)
|
|
46
|
+
options.onError(error); });
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
assertOpen() { if (this.closed)
|
|
51
|
+
throw new Error("GROUP_CLIENT_CLOSED"); if (this.withdrawn)
|
|
52
|
+
throw new Error("GROUP_ACCESS_REVOKED"); }
|
|
53
|
+
checkAuthority() {
|
|
54
|
+
if (this.withdrawn)
|
|
55
|
+
throw new Error("GROUP_ACCESS_REVOKED");
|
|
56
|
+
if (this.authority)
|
|
57
|
+
return this.authority;
|
|
58
|
+
return this.authority = this.options.access().then(access => {
|
|
59
|
+
if (this.withdrawn)
|
|
60
|
+
throw new Error("GROUP_ACCESS_REVOKED");
|
|
61
|
+
if (!access.allowed) {
|
|
62
|
+
this.withdraw();
|
|
63
|
+
throw new Error("GROUP_ACCESS_REVOKED");
|
|
64
|
+
}
|
|
65
|
+
if (this.version !== undefined && this.version !== access.version) {
|
|
66
|
+
this.withdraw();
|
|
67
|
+
throw new Error("GROUP_SCOPE_CHANGED");
|
|
68
|
+
}
|
|
69
|
+
this.version = access.version;
|
|
70
|
+
return access;
|
|
71
|
+
}).finally(() => { this.authority = undefined; });
|
|
72
|
+
}
|
|
73
|
+
entry(thread) {
|
|
74
|
+
this.assertOpen();
|
|
75
|
+
validateConversation(this.options.scope, thread);
|
|
76
|
+
if (thread.kind !== "group")
|
|
77
|
+
throw new Error("INVALID_GROUP_CONVERSATION");
|
|
78
|
+
let entry = this.entries.get(thread.id);
|
|
79
|
+
if (!entry) {
|
|
80
|
+
if (this.entries.size >= 256) {
|
|
81
|
+
const old = [...this.entries.values()].filter(value => !value.local && !value.request && !this.pins.has(value.thread.id)).sort((a, b) => a.used - b.used)[0];
|
|
82
|
+
if (!old)
|
|
83
|
+
throw new Error("GROUP_CACHE_BACKPRESSURE");
|
|
84
|
+
this.entries.delete(old.thread.id);
|
|
85
|
+
}
|
|
86
|
+
entry = { thread: { id: thread.id, kind: "group", targetId: thread.targetId, teamId: thread.teamId }, snapshot: empty, epoch: 0, token: null, loaded: false, checked: false, generation: 0, wanted: 0, attempts: 0, used: 0 };
|
|
87
|
+
this.entries.set(thread.id, entry);
|
|
88
|
+
}
|
|
89
|
+
entry.used = ++this.clock;
|
|
90
|
+
return entry;
|
|
91
|
+
}
|
|
92
|
+
snapshot(threadId) { return this.closed || this.withdrawn ? empty : this.entries.get(threadId)?.snapshot ?? empty; }
|
|
93
|
+
subscribe(listener) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }
|
|
94
|
+
pin(threadId) { this.pins.set(threadId, (this.pins.get(threadId) ?? 0) + 1); return () => { const count = (this.pins.get(threadId) ?? 1) - 1; if (count)
|
|
95
|
+
this.pins.set(threadId, count);
|
|
96
|
+
else
|
|
97
|
+
this.pins.delete(threadId); }; }
|
|
98
|
+
publish(entry, next) {
|
|
99
|
+
if (this.closed)
|
|
100
|
+
return;
|
|
101
|
+
const previous = entry.snapshot;
|
|
102
|
+
if (previous.data && next.data && JSON.stringify(previous.data) === JSON.stringify(next.data))
|
|
103
|
+
next = { ...next, data: previous.data };
|
|
104
|
+
if (previous.data === next.data && previous.loading === next.loading && previous.error === next.error)
|
|
105
|
+
return;
|
|
106
|
+
entry.snapshot = Object.freeze(next);
|
|
107
|
+
this.listeners.forEach(listener => listener(entry.thread.id));
|
|
108
|
+
}
|
|
109
|
+
normalize(value, entry) {
|
|
110
|
+
const scope = this.options.scope;
|
|
111
|
+
if (!value || value.appId !== scope.appId || value.groupId !== entry.thread.targetId || value.teamId !== scope.teamId || !validMessageIdentifier(value.ownerId) || !validMessageIdentifier(value.revision) || !Number.isSafeInteger(value.versionNumber) || value.versionNumber < 1)
|
|
112
|
+
throw new Error("INVALID_GROUP_METADATA");
|
|
113
|
+
if (value.status === "dissolved") {
|
|
114
|
+
if (value.dissolvedBy !== undefined && !validMessageIdentifier(value.dissolvedBy) || value.dissolvedAt !== undefined && (!Number.isSafeInteger(value.dissolvedAt) || value.dissolvedAt < 0))
|
|
115
|
+
throw new Error("INVALID_GROUP_METADATA");
|
|
116
|
+
return Object.freeze({ groupId: value.groupId, teamId: scope.teamId, ownerId: value.ownerId, revision: value.revision, versionNumber: value.versionNumber, status: "dissolved", ...(value.dissolvedBy ? { dissolvedBy: value.dissolvedBy } : {}), ...(value.dissolvedAt === undefined ? {} : { dissolvedAt: value.dissolvedAt }) });
|
|
117
|
+
}
|
|
118
|
+
if (value.status !== undefined && value.status !== "active" || !Array.isArray(value.members) || !value.members.length || value.members.length > 256 || !value.members.every(validMessageIdentifier) || !value.members.includes(value.ownerId) || !value.members.includes(scope.profileId) || typeof value.title !== "string" || value.avatar !== undefined && value.avatar !== null && typeof value.avatar !== "string")
|
|
119
|
+
throw new Error("INVALID_GROUP_METADATA");
|
|
120
|
+
return Object.freeze({ groupId: value.groupId, teamId: scope.teamId, ownerId: value.ownerId, title: value.title, ...(value.avatar ? { avatar: value.avatar } : {}), memberIds: Object.freeze([...new Set(value.members)].sort()), revision: value.revision, versionNumber: value.versionNumber, status: "active" });
|
|
121
|
+
}
|
|
122
|
+
cached(value, entry) {
|
|
123
|
+
return this.normalize(value.status === "dissolved" ? { appId: this.options.scope.appId, ...value } : { appId: this.options.scope.appId, ...value, members: value.memberIds }, entry);
|
|
124
|
+
}
|
|
125
|
+
local(entry) {
|
|
126
|
+
if (entry.loaded)
|
|
127
|
+
return Promise.resolve();
|
|
128
|
+
if (entry.local)
|
|
129
|
+
return entry.local;
|
|
130
|
+
this.publish(entry, { ...entry.snapshot, loading: !entry.snapshot.data });
|
|
131
|
+
entry.local = new Promise((resolve, reject) => this.localQueue.push({ entry, resolve, reject })).finally(() => { entry.local = undefined; });
|
|
132
|
+
if (!this.localScheduled) {
|
|
133
|
+
this.localScheduled = true;
|
|
134
|
+
queueMicrotask(() => { this.localScheduled = false; void this.flushLocal(); });
|
|
135
|
+
}
|
|
136
|
+
return entry.local;
|
|
137
|
+
}
|
|
138
|
+
flushLocal() {
|
|
139
|
+
const task = (async () => {
|
|
140
|
+
while (this.localQueue.length) {
|
|
141
|
+
const batch = this.localQueue.splice(0, 128);
|
|
142
|
+
try {
|
|
143
|
+
this.assertOpen();
|
|
144
|
+
await this.checkAuthority();
|
|
145
|
+
const rows = await this.cache.readMany(batch.map(item => item.entry.thread.id));
|
|
146
|
+
await this.checkAuthority();
|
|
147
|
+
this.assertOpen();
|
|
148
|
+
for (const item of batch) {
|
|
149
|
+
try {
|
|
150
|
+
const row = rows.get(item.entry.thread.id), entry = item.entry;
|
|
151
|
+
const denied = row.cached?.value.status === "access-denied";
|
|
152
|
+
let data = row.cached && !denied ? this.cached(row.cached.value, entry) : undefined;
|
|
153
|
+
if (denied)
|
|
154
|
+
this.denied.add(entry.thread.id);
|
|
155
|
+
const old = entry.snapshot.data;
|
|
156
|
+
if (old && data && old.versionNumber > data.versionNumber)
|
|
157
|
+
data = old;
|
|
158
|
+
if (old && data && old.versionNumber === data.versionNumber && JSON.stringify(old) !== JSON.stringify(data))
|
|
159
|
+
throw new Error("GROUP_REVISION_CONFLICT");
|
|
160
|
+
entry.epoch = row.epoch;
|
|
161
|
+
entry.token = row.cached ? token(row.cached.value) : null;
|
|
162
|
+
entry.loaded = true;
|
|
163
|
+
this.publish(entry, { data: denied ? undefined : data ?? old, loading: false, ...(denied ? { error: scopedGroupDenial(Object.assign(new Error("GROUP_ACCESS_DENIED"), { status: 403, threadId: entry.thread.id })) } : {}) });
|
|
164
|
+
item.resolve();
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
this.publish(item.entry, { ...item.entry.snapshot, loading: false, error });
|
|
168
|
+
item.reject(error);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
for (const item of batch) {
|
|
174
|
+
this.publish(item.entry, { loading: false, error });
|
|
175
|
+
item.reject(error);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
})().finally(() => this.localRunning.delete(task));
|
|
180
|
+
this.localRunning.add(task);
|
|
181
|
+
return task;
|
|
182
|
+
}
|
|
183
|
+
async reload(entry) { entry.loaded = false; await this.local(entry); }
|
|
184
|
+
async get(thread, options = {}) {
|
|
185
|
+
const entry = this.entry(thread);
|
|
186
|
+
await this.local(entry);
|
|
187
|
+
await this.checkAuthority();
|
|
188
|
+
this.assertOpen();
|
|
189
|
+
if (options.refresh)
|
|
190
|
+
this.denied.delete(thread.id);
|
|
191
|
+
else if (this.denied.has(thread.id))
|
|
192
|
+
throw entry.snapshot.error ?? scopedGroupDenial(Object.assign(new Error("GROUP_ACCESS_DENIED"), { status: 403, threadId: thread.id }));
|
|
193
|
+
if (entry.snapshot.data && !options.refresh) {
|
|
194
|
+
if (!entry.checked && !isOffline())
|
|
195
|
+
this.schedule(entry);
|
|
196
|
+
return entry.snapshot.data;
|
|
197
|
+
}
|
|
198
|
+
if (isOffline())
|
|
199
|
+
throw entry.snapshot.error ?? new Error("GROUP_METADATA_OFFLINE");
|
|
200
|
+
return this.request(entry);
|
|
201
|
+
}
|
|
202
|
+
schedule(entry) { if (!this.closed && !this.withdrawn && !this.denied.has(entry.thread.id) && !isOffline())
|
|
203
|
+
this.tasks.schedule(entry.thread.id, async () => { await this.get(entry.thread, { refresh: true }); }); }
|
|
204
|
+
async denyGroup(entry, error) {
|
|
205
|
+
const value = { status: "access-denied", revision: crypto.randomUUID() };
|
|
206
|
+
this.denied.add(entry.thread.id);
|
|
207
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
208
|
+
const row = await this.cache.read(entry.thread.id);
|
|
209
|
+
try {
|
|
210
|
+
await this.cache.write(entry.thread.id, value, row.epoch, row.cached ? token(row.cached.value) : null);
|
|
211
|
+
entry.epoch = row.epoch;
|
|
212
|
+
entry.token = token(value);
|
|
213
|
+
entry.checked = true;
|
|
214
|
+
this.publish(entry, { loading: false, error });
|
|
215
|
+
this.channel?.postMessage({ threadId: entry.thread.id, authority: this.version });
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
catch (failure) {
|
|
219
|
+
if (attempt === 2 || !String(failure).includes("RESPONSE_CACHE_CHANGED"))
|
|
220
|
+
throw failure;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async revoke(threadId) {
|
|
225
|
+
this.denied.add(threadId);
|
|
226
|
+
const entry = this.entries.get(threadId);
|
|
227
|
+
if (entry)
|
|
228
|
+
await this.denyGroup(entry, scopedGroupDenial(Object.assign(new Error("GROUP_ACCESS_DENIED"), { status: 403, threadId })));
|
|
229
|
+
else
|
|
230
|
+
await this.cache.remove(threadId);
|
|
231
|
+
}
|
|
232
|
+
wake() { for (const entry of this.entries.values())
|
|
233
|
+
this.schedule(entry); }
|
|
234
|
+
hint(thread, versionNumber) {
|
|
235
|
+
const entry = this.entries.get(thread.id);
|
|
236
|
+
if (!entry || this.closed || this.withdrawn || versionNumber !== undefined && versionNumber <= (entry.snapshot.data?.versionNumber ?? 0))
|
|
237
|
+
return;
|
|
238
|
+
entry.generation++;
|
|
239
|
+
entry.attempts = 0;
|
|
240
|
+
entry.wanted = Math.max(entry.wanted, versionNumber ?? 0);
|
|
241
|
+
this.schedule(entry);
|
|
242
|
+
}
|
|
243
|
+
slot(run) {
|
|
244
|
+
if (this.queue.length >= 128)
|
|
245
|
+
return Promise.reject(new Error("GROUP_NETWORK_BACKPRESSURE"));
|
|
246
|
+
return new Promise((resolve, reject) => { this.queue.push({ run, resolve, reject }); this.drain(); });
|
|
247
|
+
}
|
|
248
|
+
drain() {
|
|
249
|
+
while (!this.closed && this.active < 4 && this.queue.length) {
|
|
250
|
+
const work = this.queue.shift();
|
|
251
|
+
this.active++;
|
|
252
|
+
void work.run().then(work.resolve, work.reject).finally(() => { this.active--; this.drain(); });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
request(entry) {
|
|
256
|
+
if (entry.request)
|
|
257
|
+
return entry.request;
|
|
258
|
+
const task = this.slot(async () => {
|
|
259
|
+
this.assertOpen();
|
|
260
|
+
await this.checkAuthority();
|
|
261
|
+
const base = entry.token, generation = entry.generation, controller = new AbortController();
|
|
262
|
+
this.controllers.add(controller);
|
|
263
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
264
|
+
this.publish(entry, { ...entry.snapshot, loading: !entry.snapshot.data, error: undefined });
|
|
265
|
+
try {
|
|
266
|
+
const value = this.normalize(await this.options.load(entry.thread.targetId, controller.signal), entry);
|
|
267
|
+
await this.checkAuthority();
|
|
268
|
+
this.assertOpen();
|
|
269
|
+
if (this.denied.has(entry.thread.id))
|
|
270
|
+
throw Object.assign(new Error("GROUP_ACCESS_REVOKED"), { status: 403, threadId: entry.thread.id });
|
|
271
|
+
if (entry.snapshot.data && value.versionNumber <= entry.snapshot.data.versionNumber)
|
|
272
|
+
await this.reload(entry);
|
|
273
|
+
const old = entry.snapshot.data;
|
|
274
|
+
if (old && value.versionNumber < old.versionNumber) {
|
|
275
|
+
this.publish(entry, { data: old, loading: false });
|
|
276
|
+
return old;
|
|
277
|
+
}
|
|
278
|
+
if (old && value.versionNumber === old.versionNumber) {
|
|
279
|
+
if (value.revision !== old.revision || JSON.stringify(value) !== JSON.stringify(old))
|
|
280
|
+
throw new Error("GROUP_REVISION_CONFLICT");
|
|
281
|
+
entry.checked = true;
|
|
282
|
+
this.publish(entry, { data: old, loading: false });
|
|
283
|
+
return old;
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
await this.cache.write(entry.thread.id, value, entry.epoch, base);
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
if (!String(error).includes("RESPONSE_CACHE_CHANGED"))
|
|
290
|
+
throw error;
|
|
291
|
+
await this.reload(entry);
|
|
292
|
+
this.schedule(entry);
|
|
293
|
+
if (entry.snapshot.data)
|
|
294
|
+
return entry.snapshot.data;
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
await this.checkAuthority();
|
|
298
|
+
this.assertOpen();
|
|
299
|
+
entry.token = token(value);
|
|
300
|
+
entry.checked = true;
|
|
301
|
+
this.publish(entry, { data: value, loading: false });
|
|
302
|
+
this.channel?.postMessage({ threadId: entry.thread.id, authority: this.version });
|
|
303
|
+
return value;
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
if (!this.closed) {
|
|
307
|
+
const denied = [401, 403].includes(error?.status ?? 0);
|
|
308
|
+
this.publish(entry, { data: denied ? undefined : entry.snapshot.data, loading: false, error });
|
|
309
|
+
if (denied && await this.options.onDenied?.(error, entry.thread.id, controller.signal) === true) {
|
|
310
|
+
Object.assign(error, { threadId: entry.thread.id });
|
|
311
|
+
scopedGroupDenial(error);
|
|
312
|
+
try {
|
|
313
|
+
await this.denyGroup(entry, error);
|
|
314
|
+
}
|
|
315
|
+
catch (failure) {
|
|
316
|
+
await this.options.onDenied?.(failure, entry.thread.id, controller.signal);
|
|
317
|
+
throw failure;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
finally {
|
|
324
|
+
clearTimeout(timer);
|
|
325
|
+
this.controllers.delete(controller);
|
|
326
|
+
if (!this.closed && (generation !== entry.generation || entry.wanted > (entry.snapshot.data?.versionNumber ?? 0)) && entry.attempts++ < 4)
|
|
327
|
+
this.schedule(entry);
|
|
328
|
+
}
|
|
329
|
+
}).finally(() => { if (entry.request === task)
|
|
330
|
+
entry.request = undefined; });
|
|
331
|
+
entry.request = task;
|
|
332
|
+
return task;
|
|
333
|
+
}
|
|
334
|
+
async after(thread, minimumVersion) {
|
|
335
|
+
if (!Number.isSafeInteger(minimumVersion) || minimumVersion < 1)
|
|
336
|
+
throw new Error("INVALID_GROUP_VERSION");
|
|
337
|
+
const entry = this.entry(thread);
|
|
338
|
+
for (const delay of [0, 200, 400, 800, 1600]) {
|
|
339
|
+
if (entry.snapshot.data && entry.snapshot.data.versionNumber >= minimumVersion)
|
|
340
|
+
return entry.snapshot.data;
|
|
341
|
+
if (delay)
|
|
342
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
343
|
+
this.assertOpen();
|
|
344
|
+
try {
|
|
345
|
+
const value = await this.get(thread, { refresh: true });
|
|
346
|
+
if (value.versionNumber >= minimumVersion)
|
|
347
|
+
return value;
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
if (this.closed || this.withdrawn || isOffline() || ![503].includes(error?.status ?? 0))
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
throw new Error("GROUP_SYNC_PENDING");
|
|
355
|
+
}
|
|
356
|
+
withdraw() { this.withdrawn = true; this.tasks.close(); this.controllers.forEach(controller => controller.abort()); for (const entry of this.entries.values())
|
|
357
|
+
this.publish(entry, { loading: false, error: new Error("GROUP_ACCESS_REVOKED") }); }
|
|
358
|
+
retire(version) { this.withdraw(); return this.cache.retire(version); }
|
|
359
|
+
close() {
|
|
360
|
+
if (this.closing)
|
|
361
|
+
return this.closing;
|
|
362
|
+
this.closed = true;
|
|
363
|
+
this.tasks.close();
|
|
364
|
+
this.channel?.close();
|
|
365
|
+
this.controllers.forEach(controller => controller.abort());
|
|
366
|
+
this.listeners.clear();
|
|
367
|
+
const error = new Error("GROUP_CLIENT_CLOSED");
|
|
368
|
+
this.queue.splice(0).forEach(work => work.reject(error));
|
|
369
|
+
this.localQueue.splice(0).forEach(work => work.reject(error));
|
|
370
|
+
return this.closing = (async () => { try {
|
|
371
|
+
await Promise.allSettled([...this.entries.values()].flatMap(entry => entry.request ? [entry.request] : []));
|
|
372
|
+
await Promise.allSettled([...this.localRunning]);
|
|
373
|
+
await this.cache.close();
|
|
374
|
+
}
|
|
375
|
+
finally {
|
|
376
|
+
this.entries.clear();
|
|
377
|
+
this.pins.clear();
|
|
378
|
+
} })();
|
|
379
|
+
}
|
|
380
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Only SDK-verified failures may opt out of whole-session retirement.
|
|
2
|
+
const scoped = new WeakSet();
|
|
3
|
+
export function scopedGroupDenial(error) { scoped.add(error); return error; }
|
|
4
|
+
export function isScopedGroupDenial(error) { return error !== null && typeof error === "object" && scoped.has(error); }
|
package/lib/runtime/index.d.ts
CHANGED
|
@@ -29,3 +29,10 @@ export * from "./durableSender.js";
|
|
|
29
29
|
export * from "./chatSession.js";
|
|
30
30
|
export * from "./messageReaders.js";
|
|
31
31
|
export * from "./createChat.js";
|
|
32
|
+
export * from "./conversationRead.js";
|
|
33
|
+
export { projectConversationReading, type ReadingCandidate, type MessageReadingState, type ConversationReadingView } from "./conversationReading.js";
|
|
34
|
+
export { projectMessageTimeline, type TimelineItem, type TimelineReaction, type TimelineReactionEvent } from "./messageTimeline.js";
|
|
35
|
+
export * from "./messageLaunch.js";
|
|
36
|
+
export * from "./outgoingContent.js";
|
|
37
|
+
export type { ChatGroup } from "./groupClient.js";
|
|
38
|
+
export type { ConversationTarget } from "./conversationDirectory.js";
|
package/lib/runtime/index.js
CHANGED
|
@@ -29,3 +29,8 @@ export * from "./durableSender.js";
|
|
|
29
29
|
export * from "./chatSession.js";
|
|
30
30
|
export * from "./messageReaders.js";
|
|
31
31
|
export * from "./createChat.js";
|
|
32
|
+
export * from "./conversationRead.js";
|
|
33
|
+
export { projectConversationReading } from "./conversationReading.js";
|
|
34
|
+
export { projectMessageTimeline } from "./messageTimeline.js";
|
|
35
|
+
export * from "./messageLaunch.js";
|
|
36
|
+
export * from "./outgoingContent.js";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Dexie } from "dexie";
|
|
2
|
+
/** WebKit can invalidate an IDB connection while the application is suspended.
|
|
3
|
+
* Only replay a whole rejected transaction; never replay individual requests or clear data. */
|
|
4
|
+
export declare function recoverableTransactions(db: Dexie, isClosed: () => boolean): Dexie["transaction"];
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** WebKit can invalidate an IDB connection while the application is suspended.
|
|
2
|
+
* Only replay a whole rejected transaction; never replay individual requests or clear data. */
|
|
3
|
+
export function recoverableTransactions(db, isClosed) {
|
|
4
|
+
const transaction = (...args) => db.transaction.apply(db, args);
|
|
5
|
+
let recovery;
|
|
6
|
+
return (async (...args) => {
|
|
7
|
+
try {
|
|
8
|
+
return await transaction(...args);
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
const failure = error;
|
|
12
|
+
if (isClosed() || !(failure.name === "UnknownError" || failure.inner?.name === "UnknownError") || !/without an in-progress transaction/i.test(failure.message ?? ""))
|
|
13
|
+
throw error;
|
|
14
|
+
if (!recovery) {
|
|
15
|
+
db.close({ disableAutoOpen: false });
|
|
16
|
+
const opening = db.open();
|
|
17
|
+
recovery = opening;
|
|
18
|
+
void opening.finally(() => { if (recovery === opening)
|
|
19
|
+
recovery = undefined; }).catch(() => { });
|
|
20
|
+
}
|
|
21
|
+
await recovery;
|
|
22
|
+
if (isClosed())
|
|
23
|
+
throw error;
|
|
24
|
+
return transaction(...args);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -7,6 +7,8 @@ export type MessagePage<T> = {
|
|
|
7
7
|
resetRequired?: boolean;
|
|
8
8
|
};
|
|
9
9
|
export type MessageTransport<T> = {
|
|
10
|
+
/** Optional team-scoped authorization for fresh instances recovering a revoked cache. */
|
|
11
|
+
authorizeScope?: (signal: AbortSignal) => Promise<void>;
|
|
10
12
|
/** Authoritative history. Must include control state needed to merge old rows. */
|
|
11
13
|
load: (threadId: string, signal: AbortSignal) => Promise<MessagePage<T>>;
|
|
12
14
|
changes?: (threadId: string, after: string, signal: AbortSignal) => Promise<MessagePage<T>>;
|
|
@@ -36,9 +38,12 @@ export declare class MessageClient<T extends StoredMessage> {
|
|
|
36
38
|
private options;
|
|
37
39
|
private storage;
|
|
38
40
|
private watches;
|
|
41
|
+
private windowEdges;
|
|
42
|
+
private accessListeners;
|
|
39
43
|
private allListeners;
|
|
40
44
|
private reads;
|
|
41
45
|
private syncing;
|
|
46
|
+
private completedSyncs;
|
|
42
47
|
private paging;
|
|
43
48
|
private writes;
|
|
44
49
|
private activeWrites;
|
|
@@ -54,15 +59,37 @@ export declare class MessageClient<T extends StoredMessage> {
|
|
|
54
59
|
private queueBytes;
|
|
55
60
|
private pendingBytes;
|
|
56
61
|
private pendingRecords;
|
|
62
|
+
private legacyImports;
|
|
63
|
+
private revokingScope?;
|
|
64
|
+
private scopeReady?;
|
|
65
|
+
private scopeVersion;
|
|
66
|
+
private scopeAllowed;
|
|
67
|
+
private scopeDenied;
|
|
68
|
+
private enqueuing;
|
|
69
|
+
private ensureScope;
|
|
57
70
|
constructor(options: MessageClientOptions<T>);
|
|
71
|
+
/** Shared guard for sibling caches; it never authorizes a denied old instance. */
|
|
72
|
+
subscribeAccessRevoked(listener: () => void): () => void;
|
|
73
|
+
scopeAccess(): Promise<{
|
|
74
|
+
version: number;
|
|
75
|
+
allowed: boolean;
|
|
76
|
+
}>;
|
|
77
|
+
private authorizingScope?;
|
|
78
|
+
/** Recovery belongs to the SDK; withdrawn instances never regain authority. */
|
|
79
|
+
authorizeScope(): Promise<void>;
|
|
58
80
|
getSnapshot(thread: string): MessageSnapshot<T> | undefined;
|
|
81
|
+
/** Exact authoritative row only; migration-only rows never prove delivery. */
|
|
82
|
+
cachedMessage(thread: string, id: string): Promise<T>;
|
|
59
83
|
subscribe(thread: string, listener: () => void): () => void;
|
|
60
84
|
subscribeAll(listener: (threadId: string) => void): () => void;
|
|
61
85
|
/** Close the connection gap for saved windows with four bounded background lanes. */
|
|
62
|
-
restore(since: number): Promise<void>;
|
|
86
|
+
restore(since: number, threadId?: string): Promise<void>;
|
|
63
87
|
read(thread: string, limit?: number, refresh?: boolean): Promise<MessageSnapshot<T>>;
|
|
64
88
|
/** Application connection/wake events invalidate, rather than fetching directly. */
|
|
65
89
|
invalidate(thread: string): void;
|
|
90
|
+
private fallback;
|
|
91
|
+
/** Migration batches remain separate from authoritative message coverage. */
|
|
92
|
+
importLegacy(thread: string, messages: T[]): Promise<void>;
|
|
66
93
|
private reload;
|
|
67
94
|
/** Events are visible only after their atomic batch commits. */
|
|
68
95
|
receive(thread: string, messages: T[]): Promise<void>;
|
|
@@ -71,18 +98,18 @@ export declare class MessageClient<T extends StoredMessage> {
|
|
|
71
98
|
private release;
|
|
72
99
|
private flushWrites;
|
|
73
100
|
private runWrites;
|
|
101
|
+
private assertNetworkAvailable;
|
|
74
102
|
synchronize(thread: string): Promise<void>;
|
|
103
|
+
/** Keep an explicitly requested history range in the shared subscribed view. */
|
|
104
|
+
expandWindow(thread: string, page: MessageSnapshot<T>): Promise<void>;
|
|
75
105
|
earlier(thread: string, before: {
|
|
76
106
|
timestamp: number;
|
|
77
107
|
id: string;
|
|
78
|
-
}, limit?: number): Promise<
|
|
79
|
-
messages: T[];
|
|
80
|
-
hasEarlier: boolean;
|
|
81
|
-
resetVersion: number;
|
|
82
|
-
complete: boolean;
|
|
83
|
-
}>;
|
|
108
|
+
}, limit?: number, expand?: boolean): Promise<MessageSnapshot<T>>;
|
|
84
109
|
private fillWindow;
|
|
85
110
|
private denyView;
|
|
111
|
+
private denyScope;
|
|
112
|
+
revokeScope(): Promise<number>;
|
|
86
113
|
revoke(thread: string): Promise<void>;
|
|
87
|
-
close(): Promise<void>;
|
|
114
|
+
close(beforeDispose?: Promise<unknown>): Promise<void>;
|
|
88
115
|
}
|