@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.
Files changed (44) 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 +504 -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 +47 -0
  14. package/lib/runtime/createChat.d.ts +3 -0
  15. package/lib/runtime/createChat.js +18 -5
  16. package/lib/runtime/durableSender.d.ts +28 -4
  17. package/lib/runtime/durableSender.js +155 -34
  18. package/lib/runtime/index.d.ts +3 -0
  19. package/lib/runtime/index.js +3 -0
  20. package/lib/runtime/messageClient.d.ts +33 -6
  21. package/lib/runtime/messageClient.js +443 -71
  22. package/lib/runtime/messageControls.d.ts +19 -0
  23. package/lib/runtime/messageControls.js +44 -0
  24. package/lib/runtime/messageReaders.d.ts +12 -1
  25. package/lib/runtime/messageReaders.js +67 -3
  26. package/lib/runtime/messageStore.d.ts +41 -2
  27. package/lib/runtime/messageStore.js +212 -10
  28. package/lib/runtime/messageTimeline.d.ts +50 -0
  29. package/lib/runtime/messageTimeline.js +120 -0
  30. package/lib/runtime/readCounts.d.ts +15 -1
  31. package/lib/runtime/readCounts.js +96 -8
  32. package/lib/runtime/readIndex.d.ts +44 -6
  33. package/lib/runtime/readIndex.js +238 -11
  34. package/lib/runtime/readSync.d.ts +1 -0
  35. package/lib/runtime/readSync.js +21 -8
  36. package/lib/runtime/readingClient.d.ts +19 -0
  37. package/lib/runtime/readingClient.js +121 -18
  38. package/lib/runtime/readingContent.d.ts +5 -0
  39. package/lib/runtime/readingContent.js +23 -0
  40. package/lib/runtime/recordCache.d.ts +9 -0
  41. package/lib/runtime/recordCache.js +62 -5
  42. package/lib/runtime/responseCache.d.ts +44 -0
  43. package/lib/runtime/responseCache.js +173 -0
  44. 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
- close(): void;
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
- constructor(options) { this.options = { ...options }; }
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.request(input).finally(() => { if (this.pending.get(key) === task)
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
- close() { this.lifetime.abort(); this.pending.clear(); }
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: CoverageRow | undefined;
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): Promise<number>;
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
- return this.db.transaction("r", this.rows, this.coverage, async () => {
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
- async commit(thread, messages, coverage) {
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 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]] : []));
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 {};