@pmate/chat-sdk 0.12.0-next.3 → 0.12.0-next.30

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.
Files changed (41) hide show
  1. package/README.md +41 -1
  2. package/lib/runtime/batchedReadIndex.d.ts +11 -2
  3. package/lib/runtime/batchedReadIndex.js +21 -1
  4. package/lib/runtime/chatClient.d.ts +1 -0
  5. package/lib/runtime/chatClient.js +1 -1
  6. package/lib/runtime/chatSession.d.ts +78 -8
  7. package/lib/runtime/chatSession.js +488 -39
  8. package/lib/runtime/connectivity.d.ts +4 -0
  9. package/lib/runtime/connectivity.js +8 -0
  10. package/lib/runtime/conversationRead.d.ts +13 -0
  11. package/lib/runtime/conversationRead.js +34 -0
  12. package/lib/runtime/conversationReading.d.ts +28 -0
  13. package/lib/runtime/conversationReading.js +43 -0
  14. package/lib/runtime/createChat.d.ts +2 -0
  15. package/lib/runtime/createChat.js +18 -5
  16. package/lib/runtime/durableSender.d.ts +28 -4
  17. package/lib/runtime/durableSender.js +159 -35
  18. package/lib/runtime/index.d.ts +3 -0
  19. package/lib/runtime/index.js +3 -0
  20. package/lib/runtime/messageClient.d.ts +31 -6
  21. package/lib/runtime/messageClient.js +405 -70
  22. package/lib/runtime/messageReaders.d.ts +12 -1
  23. package/lib/runtime/messageReaders.js +67 -3
  24. package/lib/runtime/messageStore.d.ts +37 -2
  25. package/lib/runtime/messageStore.js +150 -10
  26. package/lib/runtime/messageTimeline.d.ts +33 -0
  27. package/lib/runtime/messageTimeline.js +104 -0
  28. package/lib/runtime/readCounts.d.ts +15 -1
  29. package/lib/runtime/readCounts.js +96 -8
  30. package/lib/runtime/readIndex.d.ts +32 -3
  31. package/lib/runtime/readIndex.js +203 -8
  32. package/lib/runtime/readSync.js +12 -4
  33. package/lib/runtime/readingClient.d.ts +18 -0
  34. package/lib/runtime/readingClient.js +120 -18
  35. package/lib/runtime/readingContent.d.ts +2 -0
  36. package/lib/runtime/readingContent.js +9 -0
  37. package/lib/runtime/recordCache.d.ts +9 -0
  38. package/lib/runtime/recordCache.js +62 -5
  39. package/lib/runtime/responseCache.d.ts +44 -0
  40. package/lib/runtime/responseCache.js +173 -0
  41. package/package.json +1 -1
@@ -0,0 +1,13 @@
1
+ import { type ReadIndexState } from "./readIndex.js";
2
+ /** Optional observations whose index metadata may still be in flight. */
3
+ export type ConversationReadMessage = {
4
+ id: string;
5
+ threadId: string;
6
+ mine?: boolean;
7
+ from?: string;
8
+ recalled?: boolean;
9
+ readPolicy?: "visible" | "explicit";
10
+ kind?: string;
11
+ };
12
+ /** Explicitly mark ordinary text history read; unheard audio stays unread. */
13
+ export declare function recordConversationRead(state: ReadIndexState, conversationId: string, messages?: readonly ConversationReadMessage[], operationId?: () => `${string}-${string}-${string}-${string}-${string}`): ReadIndexState;
@@ -0,0 +1,34 @@
1
+ import { recordReadIntent, sourcesInConversation } from "./readIndex.js";
2
+ /** Explicitly mark ordinary text history read; unheard audio stays unread. */
3
+ export function recordConversationRead(state, conversationId, messages = [], operationId = () => crypto.randomUUID()) {
4
+ if (!conversationId)
5
+ throw new Error("INVALID_READING_THREAD");
6
+ const sources = sourcesInConversation(state, conversationId);
7
+ if (new Set(sources.map(source => source.teamId)).size > 1)
8
+ throw new Error("READ_CONVERSATION_TEAM_MISMATCH");
9
+ let next = state;
10
+ const indexedIds = new Set();
11
+ for (const source of sources) {
12
+ const incoming = source.messages.filter(message => {
13
+ indexedIds.add(message.id);
14
+ return !message.recalled && message.from !== state.profileId && message.recipients.includes(state.profileId);
15
+ });
16
+ const latest = incoming.filter(message => message.readPolicy === "visible" && message.seq > source.readThroughSeq)
17
+ .reduce((last, message) => !last || message.seq > last.seq ? message : last, undefined);
18
+ if (latest)
19
+ next = recordReadIntent(next, { operationId: operationId(), threadId: source.id, messageId: latest.id, readPolicy: "visible" });
20
+ }
21
+ // Live/history messages can precede their read-index metadata. Persist their
22
+ // observations so leaving the page or reloading cannot resurrect the badge.
23
+ const observed = new Set(next.observedReadIntents?.filter(intent => intent.threadId === conversationId).map(intent => intent.messageId));
24
+ for (const message of messages) {
25
+ if (message.threadId !== conversationId || message.mine || message.from === state.profileId || message.recalled || indexedIds.has(message.id) || observed.has(message.id))
26
+ continue;
27
+ const readPolicy = message.readPolicy ?? (message.kind === "voice" ? "explicit" : "visible");
28
+ if (message.kind === "voice" || readPolicy === "explicit")
29
+ continue;
30
+ next = recordReadIntent(next, { operationId: operationId(), threadId: conversationId, messageId: message.id, readPolicy });
31
+ observed.add(message.id);
32
+ }
33
+ return next;
34
+ }
@@ -0,0 +1,28 @@
1
+ import { unreadInConversation, type ReadIndexState } from "./readIndex.js";
2
+ export type ReadingCandidate = {
3
+ id: string;
4
+ threadId: string;
5
+ from?: string;
6
+ mine?: boolean;
7
+ recalled?: boolean;
8
+ unread?: boolean;
9
+ attentionEligible?: boolean;
10
+ readPolicy?: "visible" | "explicit";
11
+ };
12
+ export type MessageReadingState = {
13
+ seq?: number;
14
+ read: boolean;
15
+ readPolicy: "visible" | "explicit";
16
+ };
17
+ export type ConversationReadingView = {
18
+ messageReading: Readonly<Record<string, MessageReadingState>>;
19
+ unread: ReturnType<typeof unreadInConversation> | undefined;
20
+ badgeCount: number | undefined;
21
+ };
22
+ /** One projection for indexed authority and observations awaiting indexing. A
23
+ * directory badge is optional compatibility metadata, never proof of coverage. */
24
+ export declare function projectConversationReading(state: ReadIndexState | undefined, conversation: {
25
+ id: string;
26
+ kind: "dm" | "group";
27
+ badgeCount?: number;
28
+ }, candidates?: readonly ReadingCandidate[]): ConversationReadingView;
@@ -0,0 +1,43 @@
1
+ import { sourcesInConversation, unreadInConversation } from "./readIndex.js";
2
+ /** One projection for indexed authority and observations awaiting indexing. A
3
+ * directory badge is optional compatibility metadata, never proof of coverage. */
4
+ export function projectConversationReading(state, conversation, candidates = []) {
5
+ const sources = state ? sourcesInConversation(state, conversation.id) : [];
6
+ const sourceIds = new Set([conversation.id, ...sources.map(source => source.id)]);
7
+ const indexed = new Set(), messageReading = Object.create(null);
8
+ for (const source of sources)
9
+ for (const message of source.messages) {
10
+ indexed.add(message.id);
11
+ messageReading[message.id] = { seq: message.seq, readPolicy: message.readPolicy, read: message.recalled || message.from === state.profileId || !message.recipients.includes(state.profileId) || (message.readPolicy === "explicit" ? source.explicitReadMessageIds.includes(message.id) : message.seq <= source.readThroughSeq) };
12
+ }
13
+ const observed = new Map();
14
+ for (const intent of state?.observedReadIntents ?? [])
15
+ if (sourceIds.has(intent.threadId)) {
16
+ if (!observed.has(intent.messageId))
17
+ observed.set(intent.messageId, new Set());
18
+ observed.get(intent.messageId).add(intent.readPolicy);
19
+ }
20
+ const unique = new Map(candidates.filter(message => message.threadId === conversation.id).map(message => [message.id, message]));
21
+ let pendingMessages = 0, pendingMentions = 0, observedBadges = 0;
22
+ for (const message of unique.values()) {
23
+ const incoming = !message.mine && message.from !== state?.profileId && !message.recalled;
24
+ const attention = conversation.kind === "dm" || message.attentionEligible === true;
25
+ const policy = message.readPolicy ?? "visible";
26
+ const acknowledged = observed.get(message.id)?.has(policy) === true;
27
+ if (!indexed.has(message.id) && acknowledged)
28
+ messageReading[message.id] = { read: true, readPolicy: policy };
29
+ if (!incoming)
30
+ continue;
31
+ if (acknowledged && attention)
32
+ observedBadges++;
33
+ if (indexed.has(message.id) || acknowledged || message.unread !== true)
34
+ continue;
35
+ pendingMessages++;
36
+ if (message.attentionEligible === true)
37
+ pendingMentions++;
38
+ }
39
+ const authoritative = sources.length && state ? unreadInConversation(state, conversation.id) : undefined;
40
+ const unread = authoritative ? { unreadMessageCount: authoritative.unreadMessageCount + pendingMessages, unreadMentionCount: authoritative.unreadMentionCount + pendingMentions, badgeCount: authoritative.badgeCount + (conversation.kind === "group" ? pendingMentions : pendingMessages) } : undefined;
41
+ const pendingBadge = conversation.kind === "group" ? pendingMentions : pendingMessages;
42
+ return { messageReading, unread, badgeCount: unread?.badgeCount ?? (conversation.badgeCount === undefined ? undefined : Math.max(Math.max(0, conversation.badgeCount - observedBadges), pendingBadge)) };
43
+ }
@@ -11,6 +11,8 @@ export type CreateChatOptions = {
11
11
  profileId: string;
12
12
  teamId: string;
13
13
  token: string;
14
+ /** Limit reconnect reconciliation for a dedicated conversation document. */
15
+ threadId?: string;
14
16
  endpoint?: string;
15
17
  clientInstanceId?: string;
16
18
  fetch?: typeof fetch;
@@ -1,3 +1,4 @@
1
+ import { isReadingControl } from "./readingContent.js";
1
2
  import { ChatSession } from "./chatSession.js";
2
3
  import { SocialReadClient } from "./socialReadClient.js";
3
4
  import { chatTeamId } from "./teamScope.js";
@@ -16,10 +17,20 @@ export function createChat(options) {
16
17
  };
17
18
  return new ChatSession({
18
19
  sharedConnection: options.connection,
20
+ activeThreadId: options.threadId,
19
21
  messages: { scope: { appId, accountId, profileId, teamId }, transport: { load: (thread, signal) => page(thread, signal), changes: (thread, after, signal) => page(thread, signal, { after }), older: (thread, before, signal) => page(thread, signal, { before }) }, onError },
20
22
  reading: { appId, profileId, sessionToken: token, hubHttpUrl: http, fetch: options.fetch, onError },
21
23
  connection: { appId, profileId, teamId, hubUrl: ws, clientInstanceId: options.clientInstanceId, fetchImpl: options.fetch,
22
24
  session: { token, identity: { accountId, app: appId, profileId } }, onNotification: options.onNotification, onReach: options.onReach },
25
+ readingCandidate: message => {
26
+ if (isReadingControl(message.body, appId))
27
+ return undefined;
28
+ const body = message.body;
29
+ if (!body || !["message", "quote"].includes(String(body.kind)) && !(body.kind === "contact" && ["friendRequest", "requestFriend"].includes(String(body.action))))
30
+ return undefined;
31
+ return { id: message.id, threadId: message.threadId, from: message.from, mine: message.from === profileId, recalled: message.recalled, unread: message.from !== profileId,
32
+ readPolicy: body?.contentKind === "voice" ? "explicit" : "visible", attentionEligible: Array.isArray(body.content?.mentions) && body.content.mentions.some(mention => mention?.id === profileId) };
33
+ },
23
34
  decode: message => decodeIncoming(message, { appId, profileId, teamId }), onError,
24
35
  });
25
36
  }
@@ -27,13 +38,15 @@ function decodeIncoming(message, scope) {
27
38
  if (message.opcode !== 1 || !message.body || typeof message.body !== "object")
28
39
  return;
29
40
  const body = message.body;
30
- if (body.kind !== "message" || body.content?.appId !== scope.appId || chatTeamId(body) !== scope.teamId)
41
+ const invitation = body.kind === "contact" && ["friendRequest", "requestFriend"].includes(String(body.action));
42
+ if ((!invitation && body.kind !== "message") || (invitation ? body.payload?.appId : body.content?.appId) !== scope.appId || chatTeamId(body) !== scope.teamId)
31
43
  return;
32
- if (body.threadKind !== "dm" && body.threadKind !== "group")
44
+ const threadKind = invitation ? "dm" : body.threadKind;
45
+ if (threadKind !== "dm" && threadKind !== "group")
33
46
  return;
34
- if (body.threadKind === "dm" && message.from !== scope.profileId && message.to !== scope.profileId)
47
+ if (threadKind === "dm" && message.from !== scope.profileId && message.to !== scope.profileId)
35
48
  return;
36
- const threadId = body.threadKind === "dm" ? dmThreadId(scope.appId, message.from, message.to, scope.teamId) : groupThreadId(scope.appId, message.to, scope.teamId);
37
- return { appId: scope.appId, teamId: scope.teamId, threadId, threadKind: body.threadKind, messageId: message.hash, id: message.hash,
49
+ const threadId = threadKind === "dm" ? dmThreadId(scope.appId, message.from, message.to, scope.teamId) : groupThreadId(scope.appId, message.to, scope.teamId);
50
+ return { appId: scope.appId, teamId: scope.teamId, threadId, threadKind, messageId: message.hash, id: message.hash,
38
51
  timestamp: message.t, serverCreatedAt: message.t, clientCreatedAt: message.t, from: message.from, to: message.to, body: message.body, recipients: message.recipients };
39
52
  }
@@ -8,13 +8,30 @@ export type OutgoingMessage = {
8
8
  message: ChatRuntimeMessage;
9
9
  status: "sending" | "failed" | "sent";
10
10
  confirmed?: boolean;
11
+ accessVersion?: number;
12
+ };
13
+ export type SenderScopeAccess = {
14
+ version: number;
15
+ allowed: boolean;
16
+ };
17
+ export type DurableSenderOptions = {
18
+ indexedDB?: IDBFactory;
19
+ name?: string;
20
+ access?: () => Promise<SenderScopeAccess>;
11
21
  };
12
22
  /** Persists the exact wire message before sending; retries preserve its hash. */
13
23
  export declare class DurableChatSender {
14
24
  private client;
15
25
  private onError;
26
+ private options;
16
27
  private db;
17
28
  private outbox;
29
+ private epochs;
30
+ private version;
31
+ private withdrawn;
32
+ private revoking?;
33
+ private channel?;
34
+ private accepted;
18
35
  private scope;
19
36
  private identity;
20
37
  private values;
@@ -22,12 +39,19 @@ export declare class DurableChatSender {
22
39
  private listeners;
23
40
  private running;
24
41
  private closed;
42
+ private closing;
43
+ private empty;
25
44
  private offConnection;
26
45
  private ready;
27
- constructor(client: ChatClient, scope: MessageScope, onError: (error: unknown) => void, options?: {
28
- indexedDB?: IDBFactory;
29
- name?: string;
30
- });
46
+ constructor(client: ChatClient, scope: MessageScope, onError: (error: unknown) => void, options?: DurableSenderOptions);
47
+ private readAccess;
48
+ private restore;
49
+ private checkAccess;
50
+ private withEpoch;
51
+ private deleteRows;
52
+ /** Stop disclosure immediately; durable invalidation is coordinated separately. */
53
+ withdraw(): void;
54
+ revoke(targetVersion?: number): Promise<void>;
31
55
  initialize(): Promise<void>;
32
56
  subscribe(listener: () => void): () => void;
33
57
  snapshot(threadId?: string): readonly OutgoingMessage[];
@@ -5,8 +5,15 @@ import { messageScopeKey } from "./messageStore.js";
5
5
  export class DurableChatSender {
6
6
  client;
7
7
  onError;
8
+ options;
8
9
  db;
9
10
  outbox;
11
+ epochs;
12
+ version = 0;
13
+ withdrawn = false;
14
+ revoking;
15
+ channel;
16
+ accepted = new Set();
10
17
  scope;
11
18
  identity;
12
19
  values = new Map();
@@ -14,30 +21,129 @@ export class DurableChatSender {
14
21
  listeners = new Set();
15
22
  running = new Map();
16
23
  closed = false;
24
+ closing = false;
25
+ empty = [];
17
26
  offConnection;
18
27
  ready;
19
28
  constructor(client, scope, onError, options = {}) {
20
29
  this.client = client;
21
30
  this.onError = onError;
31
+ this.options = options;
22
32
  this.scope = messageScopeKey(scope);
23
33
  this.identity = { ...scope };
24
34
  this.db = new Dexie(options.name ?? "pmate-chat-outbox", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
25
35
  this.db.version(1).stores({ outbox: "[scope+hash],scope,[scope+threadId]" });
36
+ this.db.version(2).stores({ scopeEpochs: "scope" });
26
37
  this.outbox = this.db.table("outbox");
27
- this.ready = this.outbox.where("scope").equals(this.scope).toArray().then(records => {
28
- if (this.closed)
29
- return;
30
- records.filter(record => !record.confirmed).forEach(record => this.values.set(record.hash, record));
31
- this.emit();
32
- });
38
+ this.epochs = this.db.table("scopeEpochs");
39
+ this.db.on("versionchange", () => { this.db.close({ disableAutoOpen: false }); return false; });
40
+ this.ready = this.restore();
41
+ void this.ready.catch(() => { });
42
+ if (typeof BroadcastChannel !== "undefined") {
43
+ this.channel = new BroadcastChannel(`pmate-chat-outbox:${this.scope}:${this.db.name}`);
44
+ this.channel.onmessage = event => { if (Number.isSafeInteger(event.data?.version) && event.data.version > this.version)
45
+ this.withdraw(); };
46
+ }
33
47
  this.offConnection = client.watchConnection(state => {
34
48
  if (state === "connected")
35
49
  void this.resume().catch(onError);
36
50
  });
37
51
  }
52
+ async readAccess() {
53
+ const access = await this.options.access?.();
54
+ if (access && (!Number.isSafeInteger(access.version) || access.version < 0 || typeof access.allowed !== "boolean"))
55
+ throw new Error("INVALID_OUTBOX_ACCESS");
56
+ return access;
57
+ }
58
+ async restore() {
59
+ const access = await this.readAccess();
60
+ const loaded = await this.db.transaction("rw", this.outbox, this.epochs, async () => {
61
+ const current = (await this.epochs.get(this.scope))?.version ?? 0;
62
+ const version = access?.version ?? current;
63
+ if (version < current)
64
+ throw new Error("OUTBOX_SCOPE_CHANGED");
65
+ if (version > current) {
66
+ await this.epochs.put({ scope: this.scope, version });
67
+ await this.deleteRows();
68
+ }
69
+ return { version, advanced: version > current, records: access?.allowed === false ? [] : await this.outbox.where("scope").equals(this.scope).toArray() };
70
+ });
71
+ this.version = loaded.version;
72
+ if (loaded.advanced)
73
+ this.channel?.postMessage({ version: this.version });
74
+ if (this.closed || this.withdrawn)
75
+ return;
76
+ const current = await this.readAccess();
77
+ if (current && (!current.allowed || current.version !== this.version))
78
+ return;
79
+ loaded.records.filter(record => !record.confirmed && (record.accessVersion ?? 0) === this.version).forEach(record => this.values.set(record.hash, record));
80
+ this.emit();
81
+ }
82
+ async checkAccess() {
83
+ if (this.closed)
84
+ throw new Error("CHAT_SENDER_CLOSED");
85
+ if (this.withdrawn)
86
+ throw new Error("OUTBOX_ACCESS_REVOKED");
87
+ const access = await this.readAccess();
88
+ if (access && access.version !== this.version) {
89
+ this.withdraw();
90
+ throw new Error("OUTBOX_SCOPE_CHANGED");
91
+ }
92
+ if (access && !access.allowed)
93
+ throw new Error("OUTBOX_ACCESS_REVOKED");
94
+ }
95
+ async withEpoch(mode, operation) {
96
+ await this.checkAccess();
97
+ try {
98
+ return await this.db.transaction(mode, this.outbox, this.epochs, async () => {
99
+ if (this.closed || this.withdrawn)
100
+ throw new Error("OUTBOX_ACCESS_REVOKED");
101
+ if (((await this.epochs.get(this.scope))?.version ?? 0) !== this.version)
102
+ throw new Error("OUTBOX_SCOPE_CHANGED");
103
+ return operation();
104
+ });
105
+ }
106
+ catch (error) {
107
+ if (String(error).includes("OUTBOX_SCOPE_CHANGED"))
108
+ this.withdraw();
109
+ throw error;
110
+ }
111
+ }
112
+ deleteRows() { return this.outbox.where("[scope+hash]").between([this.scope, Dexie.minKey], [this.scope, Dexie.maxKey], true, true).delete(); }
113
+ /** Stop disclosure immediately; durable invalidation is coordinated separately. */
114
+ withdraw() {
115
+ this.withdrawn = true;
116
+ this.values.clear();
117
+ this.emit();
118
+ }
119
+ revoke(targetVersion) {
120
+ if (this.closed || this.closing)
121
+ return Promise.reject(new Error("CHAT_SENDER_CLOSED"));
122
+ if (this.revoking)
123
+ return this.revoking;
124
+ this.withdraw();
125
+ return this.revoking = (async () => {
126
+ await this.ready.catch(error => { if (!String(error).includes("OUTBOX_SCOPE_CHANGED"))
127
+ throw error; });
128
+ const version = await this.db.transaction("rw", this.outbox, this.epochs, async () => {
129
+ const current = (await this.epochs.get(this.scope))?.version ?? 0;
130
+ const target = targetVersion ?? this.version + 1;
131
+ if (!Number.isSafeInteger(target) || target < 0)
132
+ throw new Error("INVALID_OUTBOX_EPOCH");
133
+ if (current >= target)
134
+ return current;
135
+ await this.epochs.put({ scope: this.scope, version: target });
136
+ await this.deleteRows();
137
+ return target;
138
+ });
139
+ this.channel?.postMessage({ version });
140
+ })();
141
+ }
38
142
  initialize() { return this.ready; }
39
143
  subscribe(listener) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }
40
144
  snapshot(threadId) {
145
+ if (this.closed || this.closing || this.withdrawn)
146
+ return this.empty;
41
147
  let snapshot = this.snapshots.get(threadId);
42
148
  if (!snapshot) {
43
149
  snapshot = [...this.values.values()].filter(record => threadId === undefined || record.threadId === threadId);
@@ -50,7 +156,8 @@ export class DurableChatSender {
50
156
  /** Resolves after the exact wire message is durable, independently of network delivery. */
51
157
  async enqueue(threadId, input) {
52
158
  const message = await this.persistOutgoing(threadId, input);
53
- void this.retry(message.hash).catch(error => this.onError(error));
159
+ if (!this.closed && !this.closing && !this.withdrawn)
160
+ void this.retry(message.hash).catch(error => this.onError(error));
54
161
  return message;
55
162
  }
56
163
  /** Compatibility API: resolves only after transport delivery. */
@@ -58,23 +165,27 @@ export class DurableChatSender {
58
165
  const message = await this.persistOutgoing(threadId, input);
59
166
  return this.retry(message.hash);
60
167
  }
61
- async persistOutgoing(threadId, input) {
62
- await this.ready;
63
- if (this.closed)
64
- throw new Error("CHAT_SENDER_CLOSED");
65
- const message = this.client.prepareChat(input.to, withChatTeam(input.body, this.identity.teamId), input.recipients);
66
- if (message.from !== this.identity.profileId)
67
- throw new Error("OUTGOING_MESSAGE_IDENTITY_MISMATCH");
68
- const record = { scope: this.scope, threadId, hash: message.hash, message, status: "sending" };
69
- await this.outbox.put(record);
70
- if (this.closed)
71
- throw new Error("CHAT_SENDER_CLOSED");
72
- this.values.set(record.hash, record);
73
- this.emit();
74
- return message;
168
+ persistOutgoing(threadId, input) {
169
+ if (this.closed || this.closing || this.withdrawn)
170
+ return Promise.reject(new Error("OUTBOX_ACCESS_REVOKED"));
171
+ const pending = (async () => {
172
+ await this.ready;
173
+ await this.checkAccess();
174
+ const message = this.client.prepareChat(input.to, withChatTeam(input.body, this.identity.teamId), input.recipients);
175
+ if (message.from !== this.identity.profileId)
176
+ throw new Error("OUTGOING_MESSAGE_IDENTITY_MISMATCH");
177
+ const record = { scope: this.scope, threadId, hash: message.hash, message, status: "sending", accessVersion: this.version };
178
+ await this.withEpoch("rw", async () => { await this.outbox.put(record); });
179
+ await this.checkAccess();
180
+ this.values.set(record.hash, record);
181
+ this.emit();
182
+ return message;
183
+ })().finally(() => this.accepted.delete(pending));
184
+ this.accepted.add(pending);
185
+ return pending;
75
186
  }
76
187
  retry(hash) {
77
- if (this.closed)
188
+ if (this.closed || this.closing || this.withdrawn)
78
189
  return Promise.reject(new Error("CHAT_SENDER_CLOSED"));
79
190
  const running = this.running.get(hash);
80
191
  if (running)
@@ -85,8 +196,8 @@ export class DurableChatSender {
85
196
  }
86
197
  async transmit(hash) {
87
198
  await this.ready;
88
- const record = await this.outbox.get([this.scope, hash]);
89
- if (!record || this.closed)
199
+ const record = await this.withEpoch("r", async () => this.outbox.get([this.scope, hash]));
200
+ if (!record || this.closed || this.withdrawn)
90
201
  throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
91
202
  if (record.status === "sent" || record.confirmed)
92
203
  return record.message;
@@ -96,6 +207,7 @@ export class DurableChatSender {
96
207
  if (!latest || latest.confirmed || latest.status === "sent")
97
208
  return latest?.message ?? record.message;
98
209
  }
210
+ await this.checkAccess();
99
211
  if (this.values.get(hash)?.status !== "sending") {
100
212
  this.values.set(hash, sending);
101
213
  this.emit();
@@ -104,7 +216,7 @@ export class DurableChatSender {
104
216
  const message = await this.client.sendPrepared(record.message);
105
217
  const sent = { ...record, message, status: "sent" };
106
218
  const committed = await this.save(sent);
107
- if (!this.closed) {
219
+ if (!this.closed && !this.withdrawn) {
108
220
  if (committed?.confirmed || !committed)
109
221
  this.values.delete(hash);
110
222
  else
@@ -114,7 +226,7 @@ export class DurableChatSender {
114
226
  return message;
115
227
  }
116
228
  catch (error) {
117
- if (!this.closed) {
229
+ if (!this.closed && !this.withdrawn) {
118
230
  const failed = { ...record, status: "failed" };
119
231
  const committed = await this.save(failed);
120
232
  if (committed?.confirmed || committed?.status === "sent") {
@@ -132,7 +244,7 @@ export class DurableChatSender {
132
244
  }
133
245
  }
134
246
  async save(record) {
135
- return this.db.transaction("rw", this.outbox, async () => {
247
+ return this.withEpoch("rw", async () => {
136
248
  const previous = await this.outbox.get([this.scope, record.hash]);
137
249
  if (!previous || previous.confirmed || previous.status === "sent")
138
250
  return previous;
@@ -143,10 +255,13 @@ export class DurableChatSender {
143
255
  /** Confirm only after the authoritative message has been durably merged.
144
256
  * A tombstone prevents another tab's late failure from recreating the outbox. */
145
257
  async confirm(hashes) {
258
+ hashes = [...new Set(hashes)];
259
+ if (!hashes.length)
260
+ return;
146
261
  await this.ready;
147
- await this.db.transaction("rw", this.outbox, async () => {
262
+ await this.withEpoch("rw", async () => {
148
263
  const previous = await this.outbox.bulkGet(hashes.map(hash => [this.scope, hash]));
149
- const confirmed = previous.filter((record) => Boolean(record)).map(record => ({ ...record, status: "sent", confirmed: true }));
264
+ const confirmed = previous.filter((record) => Boolean(record && !record.confirmed)).map(record => ({ ...record, status: "sent", confirmed: true }));
150
265
  if (confirmed.length)
151
266
  await this.outbox.bulkPut(confirmed);
152
267
  });
@@ -158,12 +273,12 @@ export class DurableChatSender {
158
273
  }
159
274
  async resume() {
160
275
  await this.ready;
161
- if (this.closed)
276
+ if (this.closed || this.closing || this.withdrawn)
162
277
  return;
163
278
  // Only interrupted, unconfirmed sends resume automatically; failed sends require retry.
164
279
  for (const record of this.values.values())
165
280
  if (record.status === "sending") {
166
- if (this.closed)
281
+ if (this.closed || this.withdrawn)
167
282
  return;
168
283
  try {
169
284
  await this.retry(record.hash);
@@ -174,11 +289,20 @@ export class DurableChatSender {
174
289
  }
175
290
  }
176
291
  async close() {
177
- this.closed = true;
292
+ this.closing = true;
178
293
  this.offConnection();
179
294
  this.listeners.clear();
180
- await this.ready;
181
- await Promise.allSettled([...this.running.values()]);
182
- this.db.close();
295
+ this.channel?.close();
296
+ this.channel = undefined;
297
+ try {
298
+ await this.ready;
299
+ await Promise.allSettled([...this.accepted, ...this.running.values(), ...(this.revoking ? [this.revoking] : [])]);
300
+ }
301
+ finally {
302
+ this.closed = true;
303
+ this.values.clear();
304
+ this.snapshots.clear();
305
+ this.db.close();
306
+ }
183
307
  }
184
308
  }
@@ -29,3 +29,6 @@ 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 } from "./messageTimeline.js";
@@ -29,3 +29,6 @@ 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";
@@ -15,6 +15,8 @@ export type MessageTransport<T> = {
15
15
  export type MessageSnapshot<T> = {
16
16
  messages: readonly T[];
17
17
  hasEarlier: boolean;
18
+ resetVersion: number;
19
+ complete: boolean;
18
20
  };
19
21
  export type MessageClientOptions<T extends StoredMessage> = {
20
22
  scope: MessageScope;
@@ -27,12 +29,15 @@ export type MessageClientOptions<T extends StoredMessage> = {
27
29
  syncWindowMs?: number;
28
30
  transactionBytes?: number;
29
31
  queueBytes?: number;
32
+ freshnessMs?: number;
30
33
  };
31
34
  /** Local-first message facade; hosts never select a cache or network source. */
32
35
  export declare class MessageClient<T extends StoredMessage> {
33
36
  private options;
34
37
  private storage;
35
38
  private watches;
39
+ private accessListeners;
40
+ private allListeners;
36
41
  private reads;
37
42
  private syncing;
38
43
  private paging;
@@ -50,12 +55,32 @@ export declare class MessageClient<T extends StoredMessage> {
50
55
  private queueBytes;
51
56
  private pendingBytes;
52
57
  private pendingRecords;
58
+ private legacyImports;
59
+ private revokingScope?;
60
+ private scopeReady?;
61
+ private scopeVersion;
62
+ private scopeAllowed;
63
+ private scopeDenied;
64
+ private enqueuing;
65
+ private ensureScope;
53
66
  constructor(options: MessageClientOptions<T>);
67
+ /** Shared guard for sibling caches; it never authorizes a denied old instance. */
68
+ subscribeAccessRevoked(listener: () => void): () => void;
69
+ scopeAccess(): Promise<{
70
+ version: number;
71
+ allowed: boolean;
72
+ }>;
54
73
  getSnapshot(thread: string): MessageSnapshot<T> | undefined;
55
74
  subscribe(thread: string, listener: () => void): () => void;
56
- read(thread: string, limit?: number): Promise<MessageSnapshot<T>>;
75
+ subscribeAll(listener: (threadId: string) => void): () => void;
76
+ /** Close the connection gap for saved windows with four bounded background lanes. */
77
+ restore(since: number, threadId?: string): Promise<void>;
78
+ read(thread: string, limit?: number, refresh?: boolean): Promise<MessageSnapshot<T>>;
57
79
  /** Application connection/wake events invalidate, rather than fetching directly. */
58
80
  invalidate(thread: string): void;
81
+ private fallback;
82
+ /** Migration batches remain separate from authoritative message coverage. */
83
+ importLegacy(thread: string, messages: T[]): Promise<void>;
59
84
  private reload;
60
85
  /** Events are visible only after their atomic batch commits. */
61
86
  receive(thread: string, messages: T[]): Promise<void>;
@@ -68,11 +93,11 @@ export declare class MessageClient<T extends StoredMessage> {
68
93
  earlier(thread: string, before: {
69
94
  timestamp: number;
70
95
  id: string;
71
- }, limit?: number): Promise<{
72
- messages: T[];
73
- hasEarlier: boolean;
74
- }>;
96
+ }, limit?: number): Promise<MessageSnapshot<T>>;
75
97
  private fillWindow;
98
+ private denyView;
99
+ private denyScope;
100
+ revokeScope(): Promise<number>;
76
101
  revoke(thread: string): Promise<void>;
77
- close(): Promise<void>;
102
+ close(beforeDispose?: Promise<unknown>): Promise<void>;
78
103
  }