@pmate/chat-sdk 0.12.0-next.4 → 0.12.0-next.40
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 +41 -1
- package/lib/runtime/batchedReadIndex.d.ts +11 -2
- package/lib/runtime/batchedReadIndex.js +21 -1
- package/lib/runtime/chatClient.d.ts +1 -0
- package/lib/runtime/chatClient.js +1 -1
- package/lib/runtime/chatSession.d.ts +78 -8
- package/lib/runtime/chatSession.js +504 -39
- package/lib/runtime/connectivity.d.ts +4 -0
- package/lib/runtime/connectivity.js +8 -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 +3 -0
- package/lib/runtime/createChat.js +18 -5
- package/lib/runtime/durableSender.d.ts +28 -4
- package/lib/runtime/durableSender.js +155 -34
- package/lib/runtime/index.d.ts +3 -0
- package/lib/runtime/index.js +3 -0
- package/lib/runtime/messageClient.d.ts +33 -6
- package/lib/runtime/messageClient.js +443 -71
- package/lib/runtime/messageControls.d.ts +19 -0
- package/lib/runtime/messageControls.js +44 -0
- package/lib/runtime/messageReaders.d.ts +12 -1
- package/lib/runtime/messageReaders.js +67 -3
- package/lib/runtime/messageStore.d.ts +41 -2
- package/lib/runtime/messageStore.js +212 -10
- package/lib/runtime/messageTimeline.d.ts +50 -0
- package/lib/runtime/messageTimeline.js +120 -0
- package/lib/runtime/readCounts.d.ts +15 -1
- package/lib/runtime/readCounts.js +96 -8
- package/lib/runtime/readIndex.d.ts +44 -6
- package/lib/runtime/readIndex.js +238 -11
- package/lib/runtime/readSync.d.ts +1 -0
- package/lib/runtime/readSync.js +21 -8
- package/lib/runtime/readingClient.d.ts +19 -0
- package/lib/runtime/readingClient.js +121 -18
- package/lib/runtime/readingContent.d.ts +5 -0
- package/lib/runtime/readingContent.js +23 -0
- package/lib/runtime/recordCache.d.ts +9 -0
- package/lib/runtime/recordCache.js +62 -5
- package/lib/runtime/responseCache.d.ts +44 -0
- package/lib/runtime/responseCache.js +173 -0
- package/package.json +1 -1
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { StoredMessage, MessageScope } from "./messageStore.js";
|
|
2
|
+
export type MessageControl = {
|
|
3
|
+
scope: string;
|
|
4
|
+
thread: string;
|
|
5
|
+
target: string;
|
|
6
|
+
kind: "recall" | "reaction";
|
|
7
|
+
actor: string;
|
|
8
|
+
emoji: string;
|
|
9
|
+
id: string;
|
|
10
|
+
timestamp: number;
|
|
11
|
+
revision: number;
|
|
12
|
+
active: boolean;
|
|
13
|
+
};
|
|
14
|
+
export type ControlKey = [string, string, string, string, string, string];
|
|
15
|
+
export declare const controlKey: (event: MessageControl) => ControlKey;
|
|
16
|
+
export declare function newerControl(old: MessageControl | undefined, next: MessageControl): MessageControl;
|
|
17
|
+
/** Body-free proofs retained independently of the visible history window. */
|
|
18
|
+
export declare function messageControls(message: StoredMessage, scope: MessageScope, key: string): MessageControl[];
|
|
19
|
+
export declare function applyMessageControls<T extends StoredMessage>(messages: T[], controls: readonly MessageControl[]): T[];
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export const controlKey = (event) => [event.scope, event.thread, event.target, event.kind, event.actor, event.emoji];
|
|
2
|
+
export function newerControl(old, next) {
|
|
3
|
+
return !old || next.revision > old.revision || next.revision === old.revision && (next.timestamp > old.timestamp || next.timestamp === old.timestamp && next.id > old.id) ? next : old;
|
|
4
|
+
}
|
|
5
|
+
const identifier = (value) => typeof value === "string" && value.length > 0 && value.length <= 256;
|
|
6
|
+
/** Body-free proofs retained independently of the visible history window. */
|
|
7
|
+
export function messageControls(message, scope, key) {
|
|
8
|
+
const row = message;
|
|
9
|
+
if (row.appId !== undefined && row.appId !== scope.appId || row.teamId !== undefined && row.teamId !== scope.teamId || !identifier(row.from))
|
|
10
|
+
return [];
|
|
11
|
+
const apps = row.body;
|
|
12
|
+
const app = apps?.content?.appId ?? apps?.payload?.appId;
|
|
13
|
+
if (app !== undefined && app !== scope.appId)
|
|
14
|
+
return [];
|
|
15
|
+
if (row.recalled || row.legacyDisplay?.recalled)
|
|
16
|
+
return [{ scope: key, thread: message.threadId, target: message.id, kind: "recall", actor: row.from, emoji: "", id: message.id, timestamp: message.timestamp, revision: 0, active: true }];
|
|
17
|
+
const body = row.body;
|
|
18
|
+
const content = body?.kind === "message" && body.content?.appId === scope.appId ? body.content : undefined;
|
|
19
|
+
const target = body?.kind === "recall" && row.appId === scope.appId ? body.targetMessageId : content?.recall?.messageId;
|
|
20
|
+
const base = { scope: key, thread: message.threadId, actor: row.from, id: message.id, timestamp: message.timestamp };
|
|
21
|
+
if (identifier(target))
|
|
22
|
+
return [{ ...base, target, kind: "recall", emoji: "", revision: 0, active: true }];
|
|
23
|
+
const reaction = content?.reaction;
|
|
24
|
+
if (body?.contentKind === "text" && reaction?.version === 1 && identifier(reaction.messageId) && identifier(reaction.emojiId) && Number.isSafeInteger(reaction.revision) && (reaction.revision ?? 0) >= 1 && typeof reaction.active === "boolean")
|
|
25
|
+
return [{ ...base, target: reaction.messageId, kind: "reaction", emoji: reaction.emojiId, revision: reaction.revision, active: reaction.active }];
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
export function applyMessageControls(messages, controls) {
|
|
29
|
+
const grouped = new Map();
|
|
30
|
+
for (const event of controls) {
|
|
31
|
+
const list = grouped.get(event.target) ?? [];
|
|
32
|
+
list.push(event);
|
|
33
|
+
grouped.set(event.target, list);
|
|
34
|
+
}
|
|
35
|
+
return messages.map(message => {
|
|
36
|
+
const facts = grouped.get(message.id);
|
|
37
|
+
if (!facts?.length)
|
|
38
|
+
return message;
|
|
39
|
+
const row = message;
|
|
40
|
+
const recalled = message.recalled || facts.some(event => event.kind === "recall" && event.actor === row.from);
|
|
41
|
+
const reactionEvents = facts.filter(event => event.kind === "reaction").map(event => ({ id: event.id, messageId: event.target, threadId: event.thread, from: event.actor, emojiId: event.emoji, active: event.active, revision: event.revision, timestamp: event.timestamp }));
|
|
42
|
+
return { ...message, ...(recalled ? { recalled: true } : {}), ...(reactionEvents.length ? { reactionEvents: [...(row.reactionEvents ?? []), ...reactionEvents] } : {}) };
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type ResponseCacheAccess } from "./responseCache.js";
|
|
2
|
+
import { type MessageScope } from "./messageStore.js";
|
|
1
3
|
export type MessageReadersPage = {
|
|
2
4
|
appId: string;
|
|
3
5
|
threadId: string;
|
|
@@ -21,6 +23,9 @@ export type MessageReadersOptions = {
|
|
|
21
23
|
token: string;
|
|
22
24
|
hubHttpUrl: string;
|
|
23
25
|
fetch?: typeof fetch;
|
|
26
|
+
scope?: MessageScope;
|
|
27
|
+
access?: ResponseCacheAccess;
|
|
28
|
+
onError?: (error: unknown) => void;
|
|
24
29
|
};
|
|
25
30
|
export declare class MessageReadersError extends Error {
|
|
26
31
|
readonly status: number;
|
|
@@ -32,8 +37,14 @@ export declare class MessageReadersClient {
|
|
|
32
37
|
private options;
|
|
33
38
|
private pending;
|
|
34
39
|
private lifetime;
|
|
40
|
+
private cache?;
|
|
41
|
+
private revoked;
|
|
42
|
+
private generation;
|
|
35
43
|
constructor(options: MessageReadersOptions);
|
|
36
44
|
getReaders(query: MessageReadersQuery, signal?: AbortSignal): Promise<MessageReadersPage>;
|
|
45
|
+
private cachedRequest;
|
|
37
46
|
private request;
|
|
38
|
-
|
|
47
|
+
withdraw(): void;
|
|
48
|
+
retireScope(version: number): Promise<void>;
|
|
49
|
+
close(): Promise<void>;
|
|
39
50
|
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { isOffline } from "./connectivity.js";
|
|
2
|
+
import { IndexedDbResponseCache, isResponseCacheAuthorityError, assertResponseCacheAccess } from "./responseCache.js";
|
|
3
|
+
import { messageScopeKey } from "./messageStore.js";
|
|
1
4
|
export class MessageReadersError extends Error {
|
|
2
5
|
status;
|
|
3
6
|
forbidden;
|
|
@@ -12,7 +15,17 @@ export class MessageReadersClient {
|
|
|
12
15
|
options;
|
|
13
16
|
pending = new Map();
|
|
14
17
|
lifetime = new AbortController();
|
|
15
|
-
|
|
18
|
+
cache;
|
|
19
|
+
revoked = false;
|
|
20
|
+
generation = 0;
|
|
21
|
+
constructor(options) {
|
|
22
|
+
this.options = { ...options };
|
|
23
|
+
if (options.scope) {
|
|
24
|
+
if (options.scope.appId !== options.appId || options.scope.profileId !== options.profileId)
|
|
25
|
+
throw new Error("MESSAGE_READERS_SCOPE_MISMATCH");
|
|
26
|
+
this.cache = new IndexedDbResponseCache(messageScopeKey(options.scope), { access: options.access });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
16
29
|
getReaders(query, signal) {
|
|
17
30
|
if (this.lifetime.signal.aborted)
|
|
18
31
|
return Promise.reject(new Error("MESSAGE_READERS_CLOSED"));
|
|
@@ -24,7 +37,7 @@ export class MessageReadersClient {
|
|
|
24
37
|
const key = JSON.stringify([input.threadId, input.messageId, input.afterProfileId, input.revision, input.limit]);
|
|
25
38
|
let task = this.pending.get(key);
|
|
26
39
|
if (!task) {
|
|
27
|
-
task = this.
|
|
40
|
+
task = this.cachedRequest(input, key).finally(() => { if (this.pending.get(key) === task)
|
|
28
41
|
this.pending.delete(key); });
|
|
29
42
|
this.pending.set(key, task);
|
|
30
43
|
}
|
|
@@ -37,7 +50,54 @@ export class MessageReadersClient {
|
|
|
37
50
|
task.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
38
51
|
});
|
|
39
52
|
}
|
|
53
|
+
async cachedRequest(input, key) {
|
|
54
|
+
// Keep only the standard SDK window. Arbitrary page sizes must not multiply
|
|
55
|
+
// durable cache entries. Revision is part of the key, so changed counts do
|
|
56
|
+
// not reuse a page from a previous membership/read projection.
|
|
57
|
+
const cache = input.limit === 50 ? this.cache : undefined;
|
|
58
|
+
if (!cache)
|
|
59
|
+
return this.request(input);
|
|
60
|
+
const stored = await cache.read(key).catch(error => { if (isResponseCacheAuthorityError(error))
|
|
61
|
+
throw error; this.options.onError?.(error); return undefined; });
|
|
62
|
+
if (this.lifetime.signal.aborted)
|
|
63
|
+
throw new Error("MESSAGE_READERS_CLOSED");
|
|
64
|
+
const generation = this.generation;
|
|
65
|
+
const cached = this.revoked ? undefined : stored?.cached;
|
|
66
|
+
if (cached && (Date.now() - cached.time < 5000 || (typeof navigator !== "undefined" && navigator.onLine === false)))
|
|
67
|
+
return structuredClone(cached.value);
|
|
68
|
+
let value;
|
|
69
|
+
try {
|
|
70
|
+
value = await this.request(input);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error instanceof MessageReadersError && error.forbidden) {
|
|
74
|
+
this.revoked = true;
|
|
75
|
+
this.generation++;
|
|
76
|
+
await this.cache?.invalidate().catch(failure => this.options.onError?.(failure));
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
if (cached && !this.revoked && generation === this.generation && !this.lifetime.signal.aborted && (error instanceof TypeError || (error instanceof MessageReadersError && error.status >= 500)))
|
|
80
|
+
return structuredClone(cached.value);
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
if (generation !== this.generation)
|
|
84
|
+
throw new Error("MESSAGE_READERS_INVALIDATED");
|
|
85
|
+
this.revoked = false;
|
|
86
|
+
if (value.resetRequired)
|
|
87
|
+
await cache?.invalidate().catch(error => this.options.onError?.(error));
|
|
88
|
+
else if (stored)
|
|
89
|
+
await cache?.write(key, value, stored.epoch).catch(error => { if (isResponseCacheAuthorityError(error))
|
|
90
|
+
throw error; this.options.onError?.(error); });
|
|
91
|
+
if (this.lifetime.signal.aborted)
|
|
92
|
+
throw new Error("MESSAGE_READERS_CLOSED");
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
40
95
|
async request(input) {
|
|
96
|
+
const scopeVersion = this.options.access ? await assertResponseCacheAccess(this.options.access) : undefined;
|
|
97
|
+
if (this.lifetime.signal.aborted)
|
|
98
|
+
throw new Error("MESSAGE_READERS_CLOSED");
|
|
99
|
+
if (isOffline())
|
|
100
|
+
throw new MessageReadersError(0, "MESSAGE_READERS_OFFLINE_UNAVAILABLE");
|
|
41
101
|
const { token, fetch: fetcher, hubHttpUrl, appId, profileId } = this.options;
|
|
42
102
|
const controller = new AbortController();
|
|
43
103
|
const cancel = () => controller.abort(this.lifetime.signal.reason);
|
|
@@ -60,6 +120,8 @@ export class MessageReadersClient {
|
|
|
60
120
|
throw new Error("INVALID_MESSAGE_READERS_RESPONSE");
|
|
61
121
|
if (this.lifetime.signal.aborted)
|
|
62
122
|
throw new Error("MESSAGE_READERS_CLOSED");
|
|
123
|
+
if (this.options.access)
|
|
124
|
+
await assertResponseCacheAccess(this.options.access, scopeVersion);
|
|
63
125
|
return data;
|
|
64
126
|
}
|
|
65
127
|
finally {
|
|
@@ -67,5 +129,7 @@ export class MessageReadersClient {
|
|
|
67
129
|
this.lifetime.signal.removeEventListener("abort", cancel);
|
|
68
130
|
}
|
|
69
131
|
}
|
|
70
|
-
|
|
132
|
+
withdraw() { this.lifetime.abort(); this.pending.clear(); }
|
|
133
|
+
retireScope(version) { return this.cache?.retire(version) ?? Promise.resolve(); }
|
|
134
|
+
close() { this.lifetime.abort(); this.pending.clear(); return this.cache?.close().catch(error => this.options.onError?.(error)) ?? Promise.resolve(); }
|
|
71
135
|
}
|
|
@@ -16,6 +16,14 @@ export type MessageCoverage = {
|
|
|
16
16
|
complete: boolean;
|
|
17
17
|
nextBefore?: string | null;
|
|
18
18
|
syncedAt?: number;
|
|
19
|
+
resetVersion?: number;
|
|
20
|
+
revoked?: boolean;
|
|
21
|
+
scopeVersion?: number;
|
|
22
|
+
};
|
|
23
|
+
export type MessageScopeAccess = {
|
|
24
|
+
scope: string;
|
|
25
|
+
version: number;
|
|
26
|
+
revoked: boolean;
|
|
19
27
|
};
|
|
20
28
|
type CoverageRow = MessageCoverage & {
|
|
21
29
|
scope: string;
|
|
@@ -25,24 +33,55 @@ export declare const messageScopeKey: (scope: MessageScope) => string;
|
|
|
25
33
|
/** Normalized browser cache. Data and coverage advance together in each commit.
|
|
26
34
|
* Merge runs inside the write transaction against the latest cross-tab state. */
|
|
27
35
|
export declare class IndexedDbMessageStore<T extends StoredMessage> {
|
|
36
|
+
private scope;
|
|
28
37
|
private merge;
|
|
29
38
|
private db;
|
|
30
39
|
private rows;
|
|
40
|
+
private legacy;
|
|
31
41
|
private coverage;
|
|
42
|
+
private accessRows;
|
|
43
|
+
private controls;
|
|
32
44
|
private key;
|
|
33
45
|
constructor(scope: MessageScope, merge: (previous: T | undefined, incoming: T) => T, options?: {
|
|
34
46
|
indexedDB?: IDBFactory;
|
|
35
47
|
name?: string;
|
|
36
48
|
});
|
|
49
|
+
get namespace(): string;
|
|
37
50
|
read(thread: string, limit?: number, before?: {
|
|
38
51
|
timestamp: number;
|
|
39
52
|
id: string;
|
|
40
53
|
}): Promise<{
|
|
41
54
|
messages: T[];
|
|
42
55
|
hasEarlier: boolean;
|
|
43
|
-
coverage:
|
|
56
|
+
coverage: MessageCoverage | undefined;
|
|
57
|
+
}>;
|
|
58
|
+
/** Migration records never claim coverage or overwrite authoritative rows. */
|
|
59
|
+
importLegacy(thread: string, messages: T[]): Promise<number>;
|
|
60
|
+
/** Bounded fallback only; callers must report an incomplete window. */
|
|
61
|
+
readFallback(thread: string, limit?: number, before?: {
|
|
62
|
+
timestamp: number;
|
|
63
|
+
id: string;
|
|
64
|
+
}): Promise<{
|
|
65
|
+
messages: T[];
|
|
66
|
+
hasEarlier: boolean;
|
|
67
|
+
coverage: MessageCoverage | undefined;
|
|
44
68
|
}>;
|
|
45
|
-
commit(thread: string, messages: T[], coverage?: MessageCoverage
|
|
69
|
+
commit(thread: string, messages: T[], coverage?: MessageCoverage, guard?: {
|
|
70
|
+
version: number;
|
|
71
|
+
authoritative?: boolean;
|
|
72
|
+
scopeVersion?: number;
|
|
73
|
+
}): Promise<number>;
|
|
74
|
+
private rememberControls;
|
|
75
|
+
private controlPage;
|
|
76
|
+
private finishPage;
|
|
77
|
+
windows(): Promise<CoverageRow[]>;
|
|
78
|
+
scopeAccess(): Promise<MessageScopeAccess>;
|
|
79
|
+
private scopedCoverage;
|
|
80
|
+
private clearScopeRows;
|
|
81
|
+
/** Persist the denial first; failed cleanup must never make cached rows readable. */
|
|
82
|
+
revokeScope(expectedVersion?: number): Promise<number>;
|
|
83
|
+
/** A durable epoch prevents another tab's pre-reset request from restoring rows. */
|
|
84
|
+
resetThread(thread: string, revoked?: boolean, scopeVersion?: number): Promise<number>;
|
|
46
85
|
removeThread(thread: string): Promise<void>;
|
|
47
86
|
clear(): Promise<void>;
|
|
48
87
|
close(): void;
|
|
@@ -1,68 +1,270 @@
|
|
|
1
1
|
import { Dexie } from "dexie";
|
|
2
|
+
import { messageControls, controlKey, newerControl, applyMessageControls } from "./messageControls.js";
|
|
2
3
|
export const messageScopeKey = (scope) => JSON.stringify([scope.appId, scope.accountId, scope.profileId, scope.teamId]);
|
|
3
4
|
/** Normalized browser cache. Data and coverage advance together in each commit.
|
|
4
5
|
* Merge runs inside the write transaction against the latest cross-tab state. */
|
|
5
6
|
export class IndexedDbMessageStore {
|
|
7
|
+
scope;
|
|
6
8
|
merge;
|
|
7
9
|
db;
|
|
8
10
|
rows;
|
|
11
|
+
legacy;
|
|
9
12
|
coverage;
|
|
13
|
+
accessRows;
|
|
14
|
+
controls;
|
|
10
15
|
key;
|
|
11
16
|
constructor(scope, merge, options = {}) {
|
|
17
|
+
this.scope = scope;
|
|
12
18
|
this.merge = merge;
|
|
13
19
|
this.key = messageScopeKey(scope);
|
|
14
20
|
this.db = new Dexie(options.name ?? "pmate-chat-messages", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
|
|
15
21
|
this.db.version(1).stores({ messages: "[scope+thread+id],[scope+thread+time+id],[scope+thread],scope", coverage: "[scope+thread],scope" });
|
|
22
|
+
this.db.version(2).stores({ legacyMessages: "[scope+thread+id],[scope+thread+time+id],[scope+thread],scope" });
|
|
23
|
+
this.db.version(3).stores({ scopeAccess: "scope" });
|
|
24
|
+
this.db.version(4).stores({ messageControls: "[scope+thread+target+kind+actor+emoji],[scope+thread+target],[scope+thread],scope" });
|
|
25
|
+
this.controls = this.db.table("messageControls");
|
|
26
|
+
this.accessRows = this.db.table("scopeAccess");
|
|
27
|
+
this.legacy = this.db.table("legacyMessages");
|
|
16
28
|
this.rows = this.db.table("messages");
|
|
17
29
|
this.coverage = this.db.table("coverage");
|
|
18
30
|
this.db.on("versionchange", () => { this.db.close({ disableAutoOpen: false }); return false; });
|
|
19
31
|
}
|
|
32
|
+
get namespace() { return this.db.name; }
|
|
20
33
|
async read(thread, limit = 100, before) {
|
|
21
34
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
22
35
|
throw new Error("INVALID_MESSAGE_LIMIT");
|
|
23
|
-
|
|
36
|
+
const page = await this.db.transaction("r", this.rows, this.coverage, this.accessRows, this.controls, async () => {
|
|
37
|
+
const access = await this.scopeAccess();
|
|
38
|
+
if (access.revoked)
|
|
39
|
+
return { messages: [], hasEarlier: false, coverage: { complete: false, revoked: true, resetVersion: 0, scopeVersion: access.version } };
|
|
24
40
|
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
41
|
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]) };
|
|
42
|
+
return this.controlPage({ messages: records.slice(0, limit).reverse().map(row => row.value), hasEarlier: records.length > limit, coverage: this.scopedCoverage(await this.coverage.get([this.key, thread]), access) });
|
|
27
43
|
});
|
|
44
|
+
return this.finishPage(thread, page);
|
|
28
45
|
}
|
|
29
|
-
|
|
46
|
+
/** Migration records never claim coverage or overwrite authoritative rows. */
|
|
47
|
+
async importLegacy(thread, messages) {
|
|
48
|
+
if (messages.length > 128)
|
|
49
|
+
throw new Error("MESSAGE_BATCH_TOO_LARGE");
|
|
50
|
+
let bytes = 0;
|
|
51
|
+
for (const message of messages) {
|
|
52
|
+
if (message.threadId !== thread || !message.id || !Number.isFinite(message.timestamp))
|
|
53
|
+
throw new Error("MESSAGE_SCOPE_MISMATCH");
|
|
54
|
+
bytes += new TextEncoder().encode(JSON.stringify(message)).byteLength;
|
|
55
|
+
if (bytes > 1_048_576)
|
|
56
|
+
throw new Error("MESSAGE_BATCH_TOO_LARGE");
|
|
57
|
+
}
|
|
58
|
+
return this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
59
|
+
const access = await this.scopeAccess();
|
|
60
|
+
const coverage = await this.coverage.get([this.key, thread]);
|
|
61
|
+
if (access.revoked || coverage?.revoked)
|
|
62
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
63
|
+
if (access.version > 0)
|
|
64
|
+
throw new Error("MESSAGE_PROJECTION_CHANGED");
|
|
65
|
+
const proofs = messages.map(message => messageControls(message, this.scope, this.key));
|
|
66
|
+
const controlsOnly = messages.length > 0 && proofs.every((events, index) => events.length > 0 && !messages[index].recalled && !messages[index].legacyDisplay?.recalled);
|
|
67
|
+
if (controlsOnly) {
|
|
68
|
+
await this.rememberControls(proofs.flat());
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
if ((coverage?.resetVersion ?? 0) > 0)
|
|
72
|
+
throw new Error("MESSAGE_PROJECTION_CHANGED");
|
|
73
|
+
const ids = [...new Set(messages.map(message => message.id))], keys = ids.map(id => [this.key, thread, id]);
|
|
74
|
+
const authoritative = await this.rows.bulkGet(keys), previous = await this.legacy.bulkGet(keys);
|
|
75
|
+
const known = new Set(authoritative.flatMap(row => row ? [row.id] : []));
|
|
76
|
+
const latest = new Map(previous.flatMap(row => row ? [[row.id, row.value]] : [])), changed = new Map();
|
|
77
|
+
for (const message of messages) {
|
|
78
|
+
if (known.has(message.id))
|
|
79
|
+
continue;
|
|
80
|
+
const old = latest.get(message.id), value = this.merge(old, message);
|
|
81
|
+
if (value.id !== message.id || value.threadId !== thread || !Number.isFinite(value.timestamp))
|
|
82
|
+
throw new Error("MESSAGE_SCOPE_MISMATCH");
|
|
83
|
+
latest.set(message.id, value);
|
|
84
|
+
if (JSON.stringify(old) !== JSON.stringify(value))
|
|
85
|
+
changed.set(message.id, { scope: this.key, thread, id: value.id, time: value.timestamp, value });
|
|
86
|
+
}
|
|
87
|
+
await this.rememberControls([...changed.values()].flatMap(row => messageControls(row.value, this.scope, this.key)));
|
|
88
|
+
if (changed.size)
|
|
89
|
+
await this.legacy.bulkPut([...changed.values()]);
|
|
90
|
+
return changed.size;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/** Bounded fallback only; callers must report an incomplete window. */
|
|
94
|
+
async readFallback(thread, limit = 100, before) {
|
|
95
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
96
|
+
throw new Error("INVALID_MESSAGE_LIMIT");
|
|
97
|
+
const page = await this.db.transaction("r", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
98
|
+
const access = await this.scopeAccess();
|
|
99
|
+
const coverage = access.revoked ? { complete: false, revoked: true, resetVersion: 0, scopeVersion: access.version } : this.scopedCoverage(await this.coverage.get([this.key, thread]), access);
|
|
100
|
+
if (coverage?.revoked)
|
|
101
|
+
return { messages: [], hasEarlier: false, coverage };
|
|
102
|
+
const read = (table) => table.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).reverse().limit(limit + 1).toArray();
|
|
103
|
+
const [canonical, legacy] = await Promise.all([read(this.rows), read(this.legacy)]);
|
|
104
|
+
const rows = [...new Map([...legacy, ...canonical].map(row => [row.id, row])).values()].sort((a, b) => a.time - b.time || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
105
|
+
return this.controlPage({ messages: rows.slice(-limit).map(row => row.value), hasEarlier: rows.length > limit, coverage });
|
|
106
|
+
});
|
|
107
|
+
return this.finishPage(thread, page);
|
|
108
|
+
}
|
|
109
|
+
async commit(thread, messages, coverage, guard) {
|
|
30
110
|
for (const message of messages)
|
|
31
111
|
if (message.threadId !== thread || !message.id || !Number.isFinite(message.timestamp))
|
|
32
112
|
throw new Error("MESSAGE_SCOPE_MISMATCH");
|
|
33
113
|
if (messages.length > 1000)
|
|
34
114
|
throw new Error("MESSAGE_BATCH_TOO_LARGE");
|
|
35
|
-
return this.db.transaction("rw", this.rows, this.coverage, async () => {
|
|
115
|
+
return this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
116
|
+
const access = await this.scopeAccess();
|
|
117
|
+
if ((guard?.scopeVersion ?? 0) !== access.version)
|
|
118
|
+
throw new Error("MESSAGE_SCOPE_CHANGED");
|
|
119
|
+
if (access.revoked) {
|
|
120
|
+
if (!guard?.authoritative)
|
|
121
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
122
|
+
await this.clearScopeRows();
|
|
123
|
+
await this.accessRows.put({ ...access, revoked: false });
|
|
124
|
+
}
|
|
125
|
+
const current = await this.coverage.get([this.key, thread]);
|
|
126
|
+
if (guard && (current?.resetVersion ?? 0) !== guard.version)
|
|
127
|
+
throw new Error("MESSAGE_PROJECTION_CHANGED");
|
|
128
|
+
if (guard && current?.revoked && !guard.authoritative)
|
|
129
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
36
130
|
const ids = [...new Set(messages.map(message => message.id))];
|
|
37
|
-
const
|
|
38
|
-
const
|
|
131
|
+
const keys = ids.map(id => [this.key, thread, id]);
|
|
132
|
+
const previous = await this.rows.bulkGet(keys), legacy = await this.legacy.bulkGet(keys);
|
|
133
|
+
const canonicalIds = new Set(previous.flatMap(row => row ? [row.id] : []));
|
|
134
|
+
const latest = new Map([...legacy, ...previous].flatMap(row => row ? [[row.id, row.value]] : []));
|
|
39
135
|
const changed = new Map();
|
|
40
136
|
for (const message of messages) {
|
|
41
137
|
const old = latest.get(message.id), value = this.merge(old, message);
|
|
42
138
|
if (value.id !== message.id || value.threadId !== thread || !Number.isFinite(value.timestamp))
|
|
43
139
|
throw new Error("MESSAGE_SCOPE_MISMATCH");
|
|
44
140
|
latest.set(message.id, value);
|
|
45
|
-
if (JSON.stringify(old) !== JSON.stringify(value))
|
|
141
|
+
if (!canonicalIds.has(message.id) || JSON.stringify(old) !== JSON.stringify(value))
|
|
46
142
|
changed.set(message.id, { scope: this.key, thread, id: value.id, time: value.timestamp, value });
|
|
47
143
|
}
|
|
144
|
+
await this.rememberControls(messages.flatMap(message => messageControls(message, this.scope, this.key)));
|
|
48
145
|
if (changed.size)
|
|
49
146
|
await this.rows.bulkPut([...changed.values()]);
|
|
147
|
+
const replaced = legacy.flatMap(row => row ? [[this.key, thread, row.id]] : []);
|
|
148
|
+
if (replaced.length)
|
|
149
|
+
await this.legacy.bulkDelete(replaced);
|
|
50
150
|
if (coverage) {
|
|
51
|
-
const old = await this.coverage.get([this.key, thread]), next = { ...coverage, scope: this.key, thread };
|
|
151
|
+
const old = await this.coverage.get([this.key, thread]), next = { ...old, ...coverage, scope: this.key, thread };
|
|
52
152
|
if (JSON.stringify(old) !== JSON.stringify(next))
|
|
53
153
|
await this.coverage.put(next);
|
|
54
154
|
}
|
|
55
155
|
return changed.size;
|
|
56
156
|
});
|
|
57
157
|
}
|
|
158
|
+
async rememberControls(events) {
|
|
159
|
+
const latest = new Map();
|
|
160
|
+
for (const event of events) {
|
|
161
|
+
const key = JSON.stringify(controlKey(event));
|
|
162
|
+
latest.set(key, newerControl(latest.get(key), event));
|
|
163
|
+
}
|
|
164
|
+
const values = [...latest.values()];
|
|
165
|
+
for (let offset = 0; offset < values.length; offset += 128) {
|
|
166
|
+
const batch = values.slice(offset, offset + 128), previous = await this.controls.bulkGet(batch.map(controlKey));
|
|
167
|
+
const changed = batch.filter((event, index) => newerControl(previous[index], event) !== previous[index]);
|
|
168
|
+
if (changed.length)
|
|
169
|
+
await this.controls.bulkPut(changed);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async controlPage(page) {
|
|
173
|
+
const events = page.messages.flatMap(message => messageControls(message, this.scope, this.key));
|
|
174
|
+
const old = events.length ? await this.controls.bulkGet(events.map(controlKey)) : [];
|
|
175
|
+
const pendingControls = events.filter((event, index) => newerControl(old[index], event) !== old[index]);
|
|
176
|
+
const facts = await this.controls.where("[scope+thread+target]").anyOf(page.messages.map(message => [this.key, message.threadId, message.id])).toArray();
|
|
177
|
+
const latest = new Map();
|
|
178
|
+
for (const event of [...facts, ...events]) {
|
|
179
|
+
const key = JSON.stringify(controlKey(event));
|
|
180
|
+
latest.set(key, newerControl(latest.get(key), event));
|
|
181
|
+
}
|
|
182
|
+
return { ...page, messages: applyMessageControls(page.messages, [...latest.values()]), pendingControls };
|
|
183
|
+
}
|
|
184
|
+
async finishPage(thread, page) {
|
|
185
|
+
const { pendingControls, ...result } = page;
|
|
186
|
+
// v3 caches migrate only source proofs from already-read pages, with no body
|
|
187
|
+
// rewrite. Steady reads remain a single read-only IndexedDB transaction.
|
|
188
|
+
if (pendingControls?.length)
|
|
189
|
+
await this.db.transaction("rw", this.controls, this.coverage, this.accessRows, async () => {
|
|
190
|
+
const access = await this.scopeAccess(), current = await this.coverage.get([this.key, thread]);
|
|
191
|
+
if (access.revoked || access.version !== (page.coverage?.scopeVersion ?? 0))
|
|
192
|
+
throw new Error("MESSAGE_SCOPE_CHANGED");
|
|
193
|
+
if ((current?.resetVersion ?? 0) !== (page.coverage?.resetVersion ?? 0))
|
|
194
|
+
throw new Error("MESSAGE_PROJECTION_CHANGED");
|
|
195
|
+
await this.rememberControls(pendingControls);
|
|
196
|
+
});
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
async windows() {
|
|
200
|
+
return this.db.transaction("r", this.coverage, this.accessRows, async () => {
|
|
201
|
+
if ((await this.scopeAccess()).revoked)
|
|
202
|
+
return [];
|
|
203
|
+
return this.coverage.where("scope").equals(this.key).toArray();
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
async scopeAccess() {
|
|
207
|
+
const access = (await this.accessRows.get(this.key)) ?? { scope: this.key, version: 0, revoked: false };
|
|
208
|
+
if (!Number.isSafeInteger(access.version) || access.version < 0 || typeof access.revoked !== "boolean")
|
|
209
|
+
throw new Error("INVALID_MESSAGE_SCOPE_ACCESS");
|
|
210
|
+
return access;
|
|
211
|
+
}
|
|
212
|
+
scopedCoverage(coverage, access) {
|
|
213
|
+
return access.version ? { ...coverage, complete: coverage?.complete ?? false, scopeVersion: access.version } : coverage;
|
|
214
|
+
}
|
|
215
|
+
async clearScopeRows() {
|
|
216
|
+
await this.controls.where("scope").equals(this.key).delete();
|
|
217
|
+
await this.rows.where("[scope+thread+id]").between([this.key, Dexie.minKey, Dexie.minKey], [this.key, Dexie.maxKey, Dexie.maxKey], true, true).delete();
|
|
218
|
+
await this.legacy.where("[scope+thread+id]").between([this.key, Dexie.minKey, Dexie.minKey], [this.key, Dexie.maxKey, Dexie.maxKey], true, true).delete();
|
|
219
|
+
await this.coverage.where("[scope+thread]").between([this.key, Dexie.minKey], [this.key, Dexie.maxKey], true, true).delete();
|
|
220
|
+
}
|
|
221
|
+
/** Persist the denial first; failed cleanup must never make cached rows readable. */
|
|
222
|
+
async revokeScope(expectedVersion) {
|
|
223
|
+
const access = await this.db.transaction("rw", this.accessRows, async () => {
|
|
224
|
+
const old = await this.scopeAccess();
|
|
225
|
+
if (expectedVersion !== undefined && old.version !== expectedVersion)
|
|
226
|
+
return old;
|
|
227
|
+
const next = { scope: this.key, version: old.version + 1, revoked: true };
|
|
228
|
+
await this.accessRows.put(next);
|
|
229
|
+
return next;
|
|
230
|
+
});
|
|
231
|
+
await this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
232
|
+
const current = await this.scopeAccess();
|
|
233
|
+
if (current.version === access.version && current.revoked)
|
|
234
|
+
await this.clearScopeRows();
|
|
235
|
+
});
|
|
236
|
+
return access.version;
|
|
237
|
+
}
|
|
238
|
+
/** A durable epoch prevents another tab's pre-reset request from restoring rows. */
|
|
239
|
+
async resetThread(thread, revoked = false, scopeVersion = 0) {
|
|
240
|
+
return this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
241
|
+
const access = await this.scopeAccess();
|
|
242
|
+
if (access.version !== scopeVersion)
|
|
243
|
+
throw new Error("MESSAGE_SCOPE_CHANGED");
|
|
244
|
+
if (access.revoked)
|
|
245
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
246
|
+
const old = await this.coverage.get([this.key, thread]);
|
|
247
|
+
const resetVersion = (old?.resetVersion ?? 0) + 1;
|
|
248
|
+
if (revoked)
|
|
249
|
+
await this.controls.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
250
|
+
await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
251
|
+
await this.legacy.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
252
|
+
await this.coverage.put({ scope: this.key, thread, complete: false, resetVersion, revoked });
|
|
253
|
+
return resetVersion;
|
|
254
|
+
});
|
|
255
|
+
}
|
|
58
256
|
async removeThread(thread) {
|
|
59
|
-
await this.db.transaction("rw", this.rows, this.coverage, async () => {
|
|
257
|
+
await this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
258
|
+
await this.controls.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
60
259
|
await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
260
|
+
await this.legacy.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
61
261
|
await this.coverage.delete([this.key, thread]);
|
|
62
262
|
});
|
|
63
263
|
}
|
|
64
264
|
async clear() {
|
|
65
|
-
await this.db.transaction("rw", this.rows, this.coverage, async () => {
|
|
265
|
+
await this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
266
|
+
await this.controls.where("scope").equals(this.key).delete();
|
|
267
|
+
await this.legacy.where("scope").equals(this.key).delete();
|
|
66
268
|
await this.rows.where("scope").equals(this.key).delete();
|
|
67
269
|
await this.coverage.where("scope").equals(this.key).delete();
|
|
68
270
|
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { StoredMessage } from "./messageStore.js";
|
|
2
|
+
import type { OutgoingMessage } from "./durableSender.js";
|
|
3
|
+
import type { ChatRuntimeMessage } from "./protocol.js";
|
|
4
|
+
import type { MessageReadingState, ReadingCandidate } from "./conversationReading.js";
|
|
5
|
+
export type TimelineReactionEvent = Reaction & {
|
|
6
|
+
threadId: string;
|
|
7
|
+
};
|
|
8
|
+
export type TimelineReaction = {
|
|
9
|
+
emojiId: string;
|
|
10
|
+
userIds: string[];
|
|
11
|
+
mine: boolean;
|
|
12
|
+
};
|
|
13
|
+
export type TimelineItem<T> = {
|
|
14
|
+
id: string;
|
|
15
|
+
threadId: string;
|
|
16
|
+
timestamp: number;
|
|
17
|
+
from?: string;
|
|
18
|
+
mine: boolean;
|
|
19
|
+
status: "sending" | "sent" | "failed" | "read";
|
|
20
|
+
recalled: boolean;
|
|
21
|
+
unread?: boolean;
|
|
22
|
+
attentionEligible?: boolean;
|
|
23
|
+
message: T | undefined;
|
|
24
|
+
reading?: MessageReadingState;
|
|
25
|
+
reactions: readonly TimelineReaction[];
|
|
26
|
+
reactionEvents: readonly TimelineReactionEvent[];
|
|
27
|
+
};
|
|
28
|
+
type Reaction = {
|
|
29
|
+
id: string;
|
|
30
|
+
messageId: string;
|
|
31
|
+
from: string;
|
|
32
|
+
emojiId: string;
|
|
33
|
+
active: boolean;
|
|
34
|
+
revision: number;
|
|
35
|
+
timestamp: number;
|
|
36
|
+
};
|
|
37
|
+
/** Derives one display stream without copying bodies or creating another cache.
|
|
38
|
+
* Outgoing rows retain their wire identity; indexed rows take precedence. */
|
|
39
|
+
export declare function projectMessageTimeline<T extends StoredMessage>(input: {
|
|
40
|
+
appId: string;
|
|
41
|
+
profileId: string;
|
|
42
|
+
threadId: string;
|
|
43
|
+
messages: readonly T[];
|
|
44
|
+
outgoing: readonly OutgoingMessage[];
|
|
45
|
+
decode: (message: ChatRuntimeMessage) => T | undefined;
|
|
46
|
+
reading: Readonly<Record<string, MessageReadingState>>;
|
|
47
|
+
candidates?: ReadonlyMap<string, ReadingCandidate>;
|
|
48
|
+
previous?: readonly TimelineItem<T>[];
|
|
49
|
+
}): readonly TimelineItem<T>[];
|
|
50
|
+
export {};
|