@pmate/chat-sdk 0.11.8 → 0.12.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,225 @@
1
+ import { WindowedTasks } from "./coalescer.js";
2
+ import { IndexedDbMessageStore, messageScopeKey, mergeStoredMessage } from "./messageStore.js";
3
+ /** Local-first message facade; hosts never select a cache or network source. */
4
+ export class MessageClient {
5
+ options;
6
+ storage;
7
+ watches = new Map();
8
+ reads = new Map();
9
+ syncing = new Map();
10
+ writes = new Map();
11
+ activeWrites = new Set();
12
+ writeTasks;
13
+ syncTasks;
14
+ abort = new AbortController();
15
+ channel;
16
+ closed = false;
17
+ closing = false;
18
+ revoked = new Set();
19
+ batchSize;
20
+ constructor(options) {
21
+ this.options = options;
22
+ this.storage = options.storage ?? new IndexedDbMessageStore(options.scope, options.merge ?? mergeStoredMessage);
23
+ this.batchSize = options.batchSize ?? 128;
24
+ if (!Number.isSafeInteger(this.batchSize) || this.batchSize < 1 || this.batchSize > 1000)
25
+ throw new Error("INVALID_MESSAGE_BATCH_SIZE");
26
+ this.writeTasks = new WindowedTasks(options.writeWindowMs ?? 16, (error, thread) => options.onError?.(error, thread));
27
+ this.syncTasks = new WindowedTasks(options.syncWindowMs ?? 1000, (error, thread) => options.onError?.(error, thread));
28
+ if (typeof BroadcastChannel !== "undefined") {
29
+ this.channel = new BroadcastChannel(`pmate-chat:${messageScopeKey(options.scope)}`);
30
+ this.channel.onmessage = event => { if (typeof event.data === "string" && this.watches.has(event.data))
31
+ void this.reload(event.data).catch(error => options.onError?.(error, event.data)); };
32
+ }
33
+ }
34
+ getSnapshot(thread) { return this.watches.get(thread)?.snapshot; }
35
+ subscribe(thread, listener) {
36
+ let watch = this.watches.get(thread);
37
+ if (!watch) {
38
+ watch = { limit: 100, snapshot: { messages: [], hasEarlier: false }, listeners: new Set() };
39
+ this.watches.set(thread, watch);
40
+ }
41
+ watch.listeners.add(listener);
42
+ return () => { watch.listeners.delete(listener); };
43
+ }
44
+ read(thread, limit = 100) {
45
+ if (this.closed || this.closing || this.revoked.has(thread))
46
+ return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
47
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
48
+ return Promise.reject(new Error("INVALID_MESSAGE_LIMIT"));
49
+ const key = JSON.stringify([thread, limit]), running = this.reads.get(key);
50
+ if (running)
51
+ return running;
52
+ const task = (async () => {
53
+ const local = await this.storage.read(thread, limit);
54
+ if (!local.coverage?.complete)
55
+ await this.synchronize(thread);
56
+ if (this.closed)
57
+ throw new Error("MESSAGE_CLIENT_CLOSED");
58
+ return this.reload(thread, limit);
59
+ })().finally(() => { if (this.reads.get(key) === task)
60
+ this.reads.delete(key); });
61
+ this.reads.set(key, task);
62
+ return task;
63
+ }
64
+ /** Application connection/wake events invalidate, rather than fetching directly. */
65
+ invalidate(thread) {
66
+ if (!this.closed && !this.closing && !this.revoked.has(thread))
67
+ this.syncTasks.schedule(thread, () => this.synchronize(thread));
68
+ }
69
+ async reload(thread, limit = this.watches.get(thread)?.limit ?? 100) {
70
+ const page = await this.storage.read(thread, limit);
71
+ if (this.closed)
72
+ throw new Error("MESSAGE_CLIENT_CLOSED");
73
+ let watch = this.watches.get(thread);
74
+ const oldById = new Map(watch?.snapshot.messages.map(message => [message.id, message]));
75
+ const messages = page.messages.map(message => {
76
+ const old = oldById.get(message.id);
77
+ return old && JSON.stringify(old) === JSON.stringify(message) ? old : message;
78
+ });
79
+ const old = watch?.snapshot;
80
+ if (old && old.hasEarlier === (page.hasEarlier || Boolean(page.coverage?.nextBefore)) && old.messages.length === messages.length && old.messages.every((message, index) => message === messages[index]))
81
+ return old;
82
+ const snapshot = { messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore) };
83
+ if (!watch) {
84
+ watch = { limit, snapshot, listeners: new Set() };
85
+ this.watches.set(thread, watch);
86
+ }
87
+ watch.limit = limit;
88
+ watch.snapshot = snapshot;
89
+ watch.listeners.forEach(listener => listener());
90
+ return snapshot;
91
+ }
92
+ /** Events are visible only after their atomic batch commits. */
93
+ receive(thread, messages) {
94
+ if (this.closed || this.closing || this.revoked.has(thread))
95
+ return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
96
+ const queue = this.writes.get(thread) ?? [];
97
+ if (messages.length > 1000 || queue.reduce((count, write) => count + write.messages.length, messages.length) > 4096)
98
+ return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
99
+ const promise = new Promise((resolve, reject) => queue.push({ messages, resolve, reject }));
100
+ this.writes.set(thread, queue);
101
+ this.writeTasks.schedule(thread, () => this.flushWrites(thread));
102
+ return promise;
103
+ }
104
+ async persist(thread, messages, checkpoint, complete, nextBefore) {
105
+ let changed = 0;
106
+ // Coverage advances only after all bounded batches have durably committed.
107
+ for (let offset = 0; offset < messages.length; offset += this.batchSize) {
108
+ if (this.closed || this.revoked.has(thread))
109
+ throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
110
+ changed += await this.storage.commit(thread, messages.slice(offset, offset + this.batchSize));
111
+ }
112
+ if (complete !== undefined || checkpoint !== undefined) {
113
+ const old = (await this.storage.read(thread, 1)).coverage;
114
+ if (this.closed || this.revoked.has(thread))
115
+ throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
116
+ await this.storage.commit(thread, [], { complete: complete ?? old?.complete ?? false, checkpoint: checkpoint ?? old?.checkpoint, nextBefore: nextBefore === undefined ? old?.nextBefore : nextBefore });
117
+ }
118
+ if (changed) {
119
+ await this.reload(thread);
120
+ this.channel?.postMessage(thread);
121
+ }
122
+ }
123
+ flushWrites(thread) {
124
+ const task = this.runWrites(thread).finally(() => this.activeWrites.delete(task));
125
+ this.activeWrites.add(task);
126
+ return task;
127
+ }
128
+ async runWrites(thread) {
129
+ const queue = this.writes.get(thread);
130
+ if (!queue?.length)
131
+ return;
132
+ this.writes.delete(thread);
133
+ try {
134
+ await this.persist(thread, queue.flatMap(write => write.messages));
135
+ queue.forEach(write => write.resolve());
136
+ }
137
+ catch (error) {
138
+ queue.forEach(write => write.reject(error));
139
+ throw error;
140
+ }
141
+ }
142
+ synchronize(thread) {
143
+ if (this.closed || this.closing || this.revoked.has(thread))
144
+ return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
145
+ const running = this.syncing.get(thread);
146
+ if (running)
147
+ return running;
148
+ const task = (async () => {
149
+ const coverage = (await this.storage.read(thread, 1)).coverage;
150
+ let after = coverage?.complete ? coverage.checkpoint : undefined;
151
+ let page = after && this.options.transport.changes
152
+ ? await this.options.transport.changes(thread, after, this.abort.signal)
153
+ : await this.options.transport.load(thread, this.abort.signal);
154
+ if (page.resetRequired) {
155
+ if (this.closed || this.revoked.has(thread))
156
+ throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
157
+ await this.storage.removeThread(thread);
158
+ after = undefined;
159
+ page = await this.options.transport.load(thread, this.abort.signal);
160
+ }
161
+ for (;;) {
162
+ if (this.closed || this.revoked.has(thread))
163
+ throw new Error("MESSAGE_ACCESS_REVOKED");
164
+ if (page.resetRequired)
165
+ throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
166
+ await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore);
167
+ if (!page.hasMoreChanges)
168
+ break;
169
+ if (!page.checkpoint || page.checkpoint === after || !this.options.transport.changes)
170
+ throw new Error("MESSAGE_SYNC_NO_PROGRESS");
171
+ after = page.checkpoint;
172
+ page = await this.options.transport.changes(thread, after, this.abort.signal);
173
+ }
174
+ })().finally(() => { if (this.syncing.get(thread) === task)
175
+ this.syncing.delete(thread); });
176
+ this.syncing.set(thread, task);
177
+ return task;
178
+ }
179
+ async earlier(thread, before, limit = 100) {
180
+ if (this.closed)
181
+ throw new Error("MESSAGE_CLIENT_CLOSED");
182
+ if (this.revoked.has(thread))
183
+ throw new Error("MESSAGE_ACCESS_REVOKED");
184
+ if (!(await this.storage.read(thread, 1)).coverage?.complete)
185
+ await this.synchronize(thread);
186
+ let page = await this.storage.read(thread, limit, before);
187
+ while (page.messages.length < limit && page.coverage?.nextBefore && this.options.transport.older) {
188
+ const token = page.coverage.nextBefore;
189
+ const remote = await this.options.transport.older(thread, token, this.abort.signal);
190
+ if (this.closed || this.revoked.has(thread))
191
+ throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
192
+ if (remote.resetRequired) {
193
+ await this.storage.removeThread(thread);
194
+ throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
195
+ }
196
+ if (remote.nextBefore === token)
197
+ throw new Error("MESSAGE_SYNC_NO_PROGRESS");
198
+ await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null);
199
+ page = await this.storage.read(thread, limit, before);
200
+ }
201
+ return { messages: page.messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore) };
202
+ }
203
+ async revoke(thread) {
204
+ this.revoked.add(thread);
205
+ const queue = this.writes.get(thread);
206
+ this.writes.delete(thread);
207
+ queue?.forEach(write => write.reject(new Error("MESSAGE_ACCESS_REVOKED")));
208
+ await this.storage.removeThread(thread);
209
+ await this.reload(thread);
210
+ this.channel?.postMessage(thread);
211
+ }
212
+ async close() {
213
+ // Flush accepted event batches before closing; new requests are rejected afterwards.
214
+ this.closing = true;
215
+ this.syncTasks.close();
216
+ this.writeTasks.close();
217
+ await Promise.all([...this.writes.keys()].map(thread => this.flushWrites(thread)));
218
+ await Promise.all([...this.activeWrites]);
219
+ this.closed = true;
220
+ this.abort.abort();
221
+ this.channel?.close();
222
+ this.watches.clear();
223
+ this.storage.close();
224
+ }
225
+ }
@@ -0,0 +1,51 @@
1
+ export type StoredMessage = {
2
+ id: string;
3
+ threadId: string;
4
+ timestamp: number;
5
+ revision?: number;
6
+ recalled?: boolean;
7
+ };
8
+ export type MessageScope = {
9
+ appId: string;
10
+ accountId: string;
11
+ profileId: string;
12
+ teamId: string;
13
+ };
14
+ export type MessageCoverage = {
15
+ checkpoint?: string;
16
+ complete: boolean;
17
+ nextBefore?: string | null;
18
+ };
19
+ type CoverageRow = MessageCoverage & {
20
+ scope: string;
21
+ thread: string;
22
+ };
23
+ export declare const messageScopeKey: (scope: MessageScope) => string;
24
+ /** Normalized browser cache. Data and coverage advance together in each commit.
25
+ * Merge runs inside the write transaction against the latest cross-tab state. */
26
+ export declare class IndexedDbMessageStore<T extends StoredMessage> {
27
+ private merge;
28
+ private db;
29
+ private rows;
30
+ private coverage;
31
+ private key;
32
+ constructor(scope: MessageScope, merge: (previous: T | undefined, incoming: T) => T, options?: {
33
+ indexedDB?: IDBFactory;
34
+ name?: string;
35
+ });
36
+ read(thread: string, limit?: number, before?: {
37
+ timestamp: number;
38
+ id: string;
39
+ }): Promise<{
40
+ messages: T[];
41
+ hasEarlier: boolean;
42
+ coverage: CoverageRow | undefined;
43
+ }>;
44
+ commit(thread: string, messages: T[], coverage?: MessageCoverage): Promise<number>;
45
+ removeThread(thread: string): Promise<void>;
46
+ clear(): Promise<void>;
47
+ close(): void;
48
+ }
49
+ /** Keep tombstones monotonic; applications can supply stricter domain reducers. */
50
+ export declare function mergeStoredMessage<T extends StoredMessage>(previous: T | undefined, incoming: T): T;
51
+ export {};
@@ -0,0 +1,81 @@
1
+ import { Dexie } from "dexie";
2
+ export const messageScopeKey = (scope) => JSON.stringify([scope.appId, scope.accountId, scope.profileId, scope.teamId]);
3
+ /** Normalized browser cache. Data and coverage advance together in each commit.
4
+ * Merge runs inside the write transaction against the latest cross-tab state. */
5
+ export class IndexedDbMessageStore {
6
+ merge;
7
+ db;
8
+ rows;
9
+ coverage;
10
+ key;
11
+ constructor(scope, merge, options = {}) {
12
+ this.merge = merge;
13
+ this.key = messageScopeKey(scope);
14
+ this.db = new Dexie(options.name ?? "pmate-chat-messages", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
15
+ this.db.version(1).stores({ messages: "[scope+thread+id],[scope+thread+time+id],[scope+thread],scope", coverage: "[scope+thread],scope" });
16
+ this.rows = this.db.table("messages");
17
+ this.coverage = this.db.table("coverage");
18
+ this.db.on("versionchange", () => { this.db.close({ disableAutoOpen: false }); return false; });
19
+ }
20
+ async read(thread, limit = 100, before) {
21
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
22
+ throw new Error("INVALID_MESSAGE_LIMIT");
23
+ return this.db.transaction("r", this.rows, this.coverage, async () => {
24
+ const range = this.rows.where("[scope+thread+time+id]").between([this.key, thread, Dexie.minKey, Dexie.minKey], [this.key, thread, before?.timestamp ?? Dexie.maxKey, before?.id ?? Dexie.maxKey], true, !before);
25
+ const records = await range.reverse().limit(limit + 1).toArray();
26
+ return { messages: records.slice(0, limit).reverse().map(row => row.value), hasEarlier: records.length > limit, coverage: await this.coverage.get([this.key, thread]) };
27
+ });
28
+ }
29
+ async commit(thread, messages, coverage) {
30
+ for (const message of messages)
31
+ if (message.threadId !== thread || !message.id || !Number.isFinite(message.timestamp))
32
+ throw new Error("MESSAGE_SCOPE_MISMATCH");
33
+ if (messages.length > 1000)
34
+ throw new Error("MESSAGE_BATCH_TOO_LARGE");
35
+ return this.db.transaction("rw", this.rows, this.coverage, async () => {
36
+ const ids = [...new Set(messages.map(message => message.id))];
37
+ const previous = await this.rows.bulkGet(ids.map(id => [this.key, thread, id]));
38
+ const latest = new Map(previous.flatMap(row => row ? [[row.id, row.value]] : []));
39
+ const changed = new Map();
40
+ for (const message of messages) {
41
+ const old = latest.get(message.id), value = this.merge(old, message);
42
+ if (value.id !== message.id || value.threadId !== thread || !Number.isFinite(value.timestamp))
43
+ throw new Error("MESSAGE_SCOPE_MISMATCH");
44
+ latest.set(message.id, value);
45
+ if (JSON.stringify(old) !== JSON.stringify(value))
46
+ changed.set(message.id, { scope: this.key, thread, id: value.id, time: value.timestamp, value });
47
+ }
48
+ if (changed.size)
49
+ await this.rows.bulkPut([...changed.values()]);
50
+ if (coverage) {
51
+ const old = await this.coverage.get([this.key, thread]), next = { ...coverage, scope: this.key, thread };
52
+ if (JSON.stringify(old) !== JSON.stringify(next))
53
+ await this.coverage.put(next);
54
+ }
55
+ return changed.size;
56
+ });
57
+ }
58
+ async removeThread(thread) {
59
+ await this.db.transaction("rw", this.rows, this.coverage, async () => {
60
+ await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
61
+ await this.coverage.delete([this.key, thread]);
62
+ });
63
+ }
64
+ async clear() {
65
+ await this.db.transaction("rw", this.rows, this.coverage, async () => {
66
+ await this.rows.where("scope").equals(this.key).delete();
67
+ await this.coverage.where("scope").equals(this.key).delete();
68
+ });
69
+ }
70
+ close() { this.db.close(); }
71
+ }
72
+ /** Keep tombstones monotonic; applications can supply stricter domain reducers. */
73
+ export function mergeStoredMessage(previous, incoming) {
74
+ if (!previous)
75
+ return incoming;
76
+ if (previous.recalled && !incoming.recalled)
77
+ return previous;
78
+ if ((previous.revision ?? 0) > (incoming.revision ?? 0))
79
+ return previous;
80
+ return incoming;
81
+ }
@@ -206,10 +206,12 @@ export class IndexedDbReadIndex {
206
206
  }
207
207
  async update(appId, profileId, transform) {
208
208
  return this.transaction("rw", async () => {
209
- const next = transform(await this.profiles.get([appId, profileId]) ?? createReadIndex(appId, profileId));
209
+ const previous = await this.profiles.get([appId, profileId]);
210
+ const next = transform(previous ?? createReadIndex(appId, profileId));
210
211
  if (next.appId !== appId || next.profileId !== profileId || next.schemaVersion !== 1)
211
212
  throw new Error("READ_IDENTITY_MISMATCH");
212
- await this.profiles.put(next, [appId, profileId]);
213
+ if (JSON.stringify(next) !== JSON.stringify(previous))
214
+ await this.profiles.put(next, [appId, profileId]);
213
215
  return next;
214
216
  });
215
217
  }
@@ -0,0 +1,40 @@
1
+ import { type ReadIndexPersistence, type ReadIndexState } from "./readIndex.js";
2
+ export type ReadingClientOptions = {
3
+ appId: string;
4
+ profileId: string;
5
+ sessionToken: string;
6
+ hubHttpUrl: string;
7
+ persistence?: ReadIndexPersistence;
8
+ fetch?: typeof fetch;
9
+ onError?: (error: unknown) => void;
10
+ onRecovered?: () => void;
11
+ };
12
+ /** Owns durable local reads, network coalescing, retries and state notifications.
13
+ * Hosts report actual visibility; loading or subscribing never marks messages read. */
14
+ export declare class ReadingClient {
15
+ private options;
16
+ private storage;
17
+ private persistence;
18
+ private sync;
19
+ private tasks;
20
+ private listeners;
21
+ private state?;
22
+ private closed;
23
+ private retry?;
24
+ private retryDelay;
25
+ private offWake?;
26
+ private generation;
27
+ constructor(options: ReadingClientOptions);
28
+ getSnapshot: () => ReadIndexState | undefined;
29
+ subscribe: (listener: () => void) => () => void;
30
+ private publish;
31
+ private failed;
32
+ start(): Promise<void>;
33
+ refresh: () => void;
34
+ synchronize(targetCursor?: string, profileId?: string): Promise<ReadIndexState>;
35
+ update(transform: (state: ReadIndexState) => ReadIndexState): Promise<ReadIndexState>;
36
+ recordVisible(threadId: string, messageIds: string[]): Promise<ReadIndexState>;
37
+ recordExplicit(threadId: string, messageIds: string[]): Promise<ReadIndexState>;
38
+ private record;
39
+ close(): Promise<void>;
40
+ }
@@ -0,0 +1,115 @@
1
+ import { BatchedReadIndex } from "./batchedReadIndex.js";
2
+ import { WindowedTasks } from "./coalescer.js";
3
+ import { IndexedDbReadIndex, recordReadIntent } from "./readIndex.js";
4
+ import { ReadSyncClient } from "./readSync.js";
5
+ /** Owns durable local reads, network coalescing, retries and state notifications.
6
+ * Hosts report actual visibility; loading or subscribing never marks messages read. */
7
+ export class ReadingClient {
8
+ options;
9
+ storage;
10
+ persistence;
11
+ sync;
12
+ tasks;
13
+ listeners = new Set();
14
+ state;
15
+ closed = false;
16
+ retry;
17
+ retryDelay = 1000;
18
+ offWake;
19
+ generation = 0;
20
+ constructor(options) {
21
+ this.options = options;
22
+ const storage = options.persistence ?? (this.storage = new IndexedDbReadIndex());
23
+ this.persistence = new BatchedReadIndex(storage);
24
+ this.tasks = new WindowedTasks(1000, error => this.failed(error));
25
+ this.sync = new ReadSyncClient({ ...options, persistence: this.persistence, onChange: state => this.publish(state) });
26
+ }
27
+ getSnapshot = () => this.state;
28
+ subscribe = (listener) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; };
29
+ publish(state) {
30
+ if (this.closed || JSON.stringify(state) === JSON.stringify(this.state))
31
+ return;
32
+ this.state = state;
33
+ this.listeners.forEach(listener => listener());
34
+ }
35
+ failed(error) {
36
+ if (this.closed)
37
+ return;
38
+ this.options.onError?.(error);
39
+ if (/HTTP_(400|401|403)/.test(String(error)) || (typeof navigator !== "undefined" && !navigator.onLine) || this.retry)
40
+ return;
41
+ this.retry = setTimeout(() => { this.retry = undefined; this.refresh(); }, this.retryDelay);
42
+ this.retryDelay = Math.min(this.retryDelay * 2, 30_000);
43
+ }
44
+ async start() {
45
+ if (this.closed)
46
+ throw new Error("READING_CLIENT_CLOSED");
47
+ this.publish(await this.sync.loadLocal());
48
+ if (this.closed)
49
+ return;
50
+ if (typeof window !== "undefined" && !this.offWake) {
51
+ const wake = () => this.refresh();
52
+ window.addEventListener("online", wake);
53
+ window.addEventListener("focus", wake);
54
+ this.offWake = () => { window.removeEventListener("online", wake); window.removeEventListener("focus", wake); };
55
+ }
56
+ this.refresh();
57
+ }
58
+ refresh = () => {
59
+ if (!this.closed)
60
+ this.tasks.schedule("reading", async () => { await this.synchronize(); });
61
+ };
62
+ async synchronize(targetCursor, profileId) {
63
+ if (this.closed)
64
+ throw new Error("READING_CLIENT_CLOSED");
65
+ if (profileId !== undefined && profileId !== this.options.profileId)
66
+ throw new Error("Reading reach identity mismatch");
67
+ if (targetCursor !== undefined && !/^v1:\d+$/.test(targetCursor))
68
+ throw new Error("Invalid reading reach cursor");
69
+ const generation = this.generation;
70
+ for (let attempt = 0; attempt < 2; attempt++) {
71
+ const next = await this.sync.synchronize();
72
+ if (this.closed || generation !== this.generation)
73
+ throw new Error("READING_CLIENT_CLOSED");
74
+ const cursor = /^(?:teams:[0-9a-f]{64}:)?v1:(\d+)$/.exec(next.syncCursor);
75
+ if (targetCursor === undefined || (cursor && BigInt(cursor[1]) >= BigInt(targetCursor.slice(3)))) {
76
+ clearTimeout(this.retry);
77
+ this.retry = undefined;
78
+ this.retryDelay = 1000;
79
+ this.options.onRecovered?.();
80
+ if (next.pending.length)
81
+ this.refresh();
82
+ return next;
83
+ }
84
+ }
85
+ throw new Error("Reading projection has not reached the announced version");
86
+ }
87
+ async update(transform) {
88
+ if (this.closed)
89
+ throw new Error("READING_CLIENT_CLOSED");
90
+ const next = await this.persistence.update(this.options.appId, this.options.profileId, transform);
91
+ if (!this.closed) {
92
+ this.publish(next);
93
+ if (next.pending.length || next.observedReadIntents?.length)
94
+ this.refresh();
95
+ }
96
+ return next;
97
+ }
98
+ recordVisible(threadId, messageIds) { return this.record(threadId, messageIds, "visible"); }
99
+ recordExplicit(threadId, messageIds) { return this.record(threadId, messageIds, "explicit"); }
100
+ record(threadId, ids, readPolicy) {
101
+ const messageIds = [...new Set(ids)];
102
+ return this.update(state => messageIds.reduce((next, messageId) => recordReadIntent(next, { threadId, messageId, readPolicy, operationId: crypto.randomUUID() }), state));
103
+ }
104
+ async close() {
105
+ this.closed = true;
106
+ this.generation++;
107
+ this.offWake?.();
108
+ this.tasks.close();
109
+ clearTimeout(this.retry);
110
+ this.sync.stop();
111
+ this.listeners.clear();
112
+ await this.persistence.close();
113
+ await this.storage?.close();
114
+ }
115
+ }
@@ -0,0 +1,41 @@
1
+ export type RecordCacheOptions<T> = {
2
+ scope: string;
3
+ key: (value: T) => string;
4
+ merge: (previous: T | undefined, incoming: T) => T;
5
+ indexedDB?: IDBFactory;
6
+ name?: string;
7
+ onError?: (error: unknown) => void;
8
+ };
9
+ /** In-memory control-event index backed by bounded asynchronous IndexedDB batches.
10
+ * Legacy storage is read only during open; the hot path never uses localStorage. */
11
+ export declare class IndexedDbRecordCache<T> {
12
+ private options;
13
+ private db;
14
+ private records;
15
+ private metadata;
16
+ private values;
17
+ private snapshotValue;
18
+ private pending;
19
+ private waiters;
20
+ private tasks;
21
+ private ready?;
22
+ private closed;
23
+ private closing;
24
+ private initialized;
25
+ private running?;
26
+ private channel?;
27
+ private listeners;
28
+ constructor(options: RecordCacheOptions<T>);
29
+ open(legacy?: () => {
30
+ values: T[];
31
+ clear: () => void;
32
+ }): Promise<void>;
33
+ snapshot(): readonly T[];
34
+ subscribe(listener: () => void): () => void;
35
+ private reload;
36
+ upsert(incoming: T[]): Promise<void>;
37
+ private write;
38
+ flush(): Promise<void>;
39
+ private runFlush;
40
+ close(): Promise<void>;
41
+ }