@pmate/chat-sdk 0.12.0-next.57 → 0.12.0-next.59

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,35 @@
1
+ import { Dexie } from "dexie";
2
+ export type ReadMutation = {
3
+ table: "readingThreads" | "readingMessages" | "readingPositions";
4
+ key: string[];
5
+ value?: unknown;
6
+ };
7
+ type Plan = {
8
+ profileKey: string[];
9
+ header: unknown;
10
+ versions?: unknown;
11
+ };
12
+ /** Cross-document serialization; the persisted journal is the crash boundary. */
13
+ export declare function lockReadOwner<T>(factory: IDBFactory, name: string, owner: string, run: () => Promise<T>, fallback?: (run: () => Promise<T>) => Promise<T>): Promise<T>;
14
+ /** Stage the full plan first, roll forward bounded batches, publish cursor last. */
15
+ export declare class ReadBatchJournal {
16
+ private db;
17
+ private closed;
18
+ private rows;
19
+ private knownOwners;
20
+ private leases;
21
+ private acquiring;
22
+ constructor(db: Dexie, closed: () => boolean);
23
+ lease<T>(owner: string, run: () => Promise<T>): Promise<T>;
24
+ releaseLeases(): Promise<void>;
25
+ checkLeases(renew?: boolean): Promise<void>;
26
+ private check;
27
+ private tables;
28
+ recover(owner: string): Promise<void>;
29
+ private cleanup;
30
+ private apply;
31
+ private validatePlan;
32
+ private finish;
33
+ commit(owner: string, operations: Iterable<ReadMutation>, plan: Plan): Promise<void>;
34
+ }
35
+ export {};
@@ -0,0 +1,210 @@
1
+ import { Dexie } from "dexie";
2
+ const LIMIT = 124, BYTES = 1024 * 1024, LEASE_MS = 5_000;
3
+ const size = (value) => JSON.stringify(value).length * 3;
4
+ const localLocks = new WeakMap();
5
+ /** Cross-document serialization; the persisted journal is the crash boundary. */
6
+ export async function lockReadOwner(factory, name, owner, run, fallback) {
7
+ const key = JSON.stringify(["pmate-reading", name, owner]);
8
+ if (typeof navigator !== "undefined" && navigator.locks)
9
+ return navigator.locks.request(key, run);
10
+ if (typeof window !== "undefined") {
11
+ if (!fallback)
12
+ throw new Error("READ_STORAGE_LOCK_UNAVAILABLE");
13
+ return fallback(run);
14
+ }
15
+ let locks = localLocks.get(factory);
16
+ if (!locks) {
17
+ locks = new Map();
18
+ localLocks.set(factory, locks);
19
+ }
20
+ const previous = locks.get(key) ?? Promise.resolve();
21
+ let release;
22
+ const current = new Promise(resolve => { release = resolve; });
23
+ locks.set(key, current);
24
+ await previous;
25
+ try {
26
+ return await run();
27
+ }
28
+ finally {
29
+ release();
30
+ if (locks.get(key) === current)
31
+ locks.delete(key);
32
+ }
33
+ }
34
+ /** Stage the full plan first, roll forward bounded batches, publish cursor last. */
35
+ export class ReadBatchJournal {
36
+ db;
37
+ closed;
38
+ rows;
39
+ knownOwners = new Set();
40
+ leases = new Map();
41
+ acquiring = new Set();
42
+ constructor(db, closed) {
43
+ this.db = db;
44
+ this.closed = closed;
45
+ this.rows = db.table("readBatches");
46
+ }
47
+ async lease(owner, run) {
48
+ const token = crypto.randomUUID();
49
+ for (;;) {
50
+ this.check();
51
+ const attempt = (async () => {
52
+ const acquired = await this.db.transaction("rw", this.rows, async () => {
53
+ const old = (await this.rows.get([owner, "lease"]))?.lease;
54
+ if (old && old.expires > Date.now())
55
+ return false;
56
+ await this.rows.put({ owner, key: "lease", lease: { token, expires: Date.now() + LEASE_MS } });
57
+ return true;
58
+ });
59
+ if (acquired)
60
+ this.leases.set(owner, token);
61
+ return acquired;
62
+ })();
63
+ this.acquiring.add(attempt);
64
+ const acquired = await attempt.finally(() => this.acquiring.delete(attempt));
65
+ if (acquired)
66
+ break;
67
+ await new Promise(resolve => setTimeout(resolve, 25));
68
+ }
69
+ try {
70
+ const result = await run();
71
+ this.check();
72
+ await this.db.transaction("r", this.rows, () => this.checkLeases());
73
+ return result;
74
+ }
75
+ finally {
76
+ this.leases.delete(owner);
77
+ if (!this.closed())
78
+ await this.db.transaction("rw", this.rows, async () => { if ((await this.rows.get([owner, "lease"]))?.lease?.token === token)
79
+ await this.rows.delete([owner, "lease"]); });
80
+ }
81
+ }
82
+ async releaseLeases() {
83
+ await Promise.allSettled([...this.acquiring]);
84
+ for (const [owner, token] of this.leases)
85
+ await this.db.transaction("rw", this.rows, async () => { if ((await this.rows.get([owner, "lease"]))?.lease?.token === token)
86
+ await this.rows.delete([owner, "lease"]); });
87
+ }
88
+ async checkLeases(renew = false) {
89
+ for (const [owner, token] of this.leases) {
90
+ if ((await this.rows.get([owner, "lease"]))?.lease?.token !== token)
91
+ throw new Error("READ_STORAGE_LOCK_LOST");
92
+ if (renew)
93
+ await this.rows.put({ owner, key: "lease", lease: { token, expires: Date.now() + LEASE_MS } });
94
+ }
95
+ }
96
+ check() { if (this.closed())
97
+ throw new Error("READ_STORAGE_CLOSED"); }
98
+ tables() { return [this.rows, ...["readingProfiles", "readingVersions", "readingThreads", "readingMessages", "readingPositions"].map(name => this.db.table(name))]; }
99
+ async recover(owner) {
100
+ this.check();
101
+ const active = (await this.rows.get([owner, "active"]))?.active;
102
+ if (!active) {
103
+ if (!this.knownOwners.has(owner)) {
104
+ await this.cleanup(owner);
105
+ this.knownOwners.add(owner);
106
+ }
107
+ return;
108
+ }
109
+ const plan = (await this.rows.get([owner, `${active.id}:plan`]))?.plan;
110
+ if (!plan || typeof active.id !== "string" || !Number.isSafeInteger(active.next) || !Number.isSafeInteger(active.total) || active.next < 0 || active.total < 1 || active.next > active.total)
111
+ throw new Error("READ_STORAGE_INCOMPLETE");
112
+ this.validatePlan(owner, plan);
113
+ while (active.next < active.total) {
114
+ this.check();
115
+ await this.db.transaction("rw", this.tables(), async () => {
116
+ await this.checkLeases();
117
+ const chunk = await this.rows.get([owner, `${active.id}:${active.next}`]);
118
+ if (!chunk?.operations || chunk.operations.length > LIMIT || chunk.operations.some(operation => !["readingThreads", "readingMessages", "readingPositions"].includes(operation.table) || operation.key[0] !== owner || operation.value !== undefined && operation.value.owner !== owner))
119
+ throw new Error("READ_STORAGE_INCOMPLETE");
120
+ await this.apply(chunk.operations);
121
+ await this.rows.put({ owner, key: "active", active: { ...active, next: active.next + 1 } });
122
+ await this.checkLeases(true);
123
+ });
124
+ active.next++;
125
+ await new Promise(resolve => setTimeout(resolve, 0));
126
+ }
127
+ this.check();
128
+ await this.db.transaction("rw", this.tables(), async () => { await this.checkLeases(); await this.finish(plan); await this.rows.delete([owner, "active"]); await this.checkLeases(true); });
129
+ await this.cleanup(owner);
130
+ this.knownOwners.add(owner);
131
+ }
132
+ async cleanup(owner) {
133
+ for (;;) {
134
+ this.check();
135
+ const keys = await this.rows.where("owner").equals(owner).limit(LIMIT).primaryKeys();
136
+ const removable = keys.filter(key => key[1] !== "lease");
137
+ if (removable.length)
138
+ await this.db.transaction("rw", this.rows, async () => { await this.checkLeases(); await this.rows.bulkDelete(removable); await this.checkLeases(true); });
139
+ const count = keys.length;
140
+ if (count < LIMIT)
141
+ return;
142
+ await new Promise(resolve => setTimeout(resolve, 0));
143
+ }
144
+ }
145
+ async apply(operations) {
146
+ for (const name of ["readingThreads", "readingMessages", "readingPositions"]) {
147
+ const selected = operations.filter(operation => operation.table === name);
148
+ const writes = selected.filter(operation => operation.value !== undefined).map(operation => operation.value);
149
+ const deletes = selected.filter(operation => operation.value === undefined).map(operation => operation.key);
150
+ if (writes.length)
151
+ await this.db.table(name).bulkPut(writes);
152
+ if (deletes.length)
153
+ await this.db.table(name).bulkDelete(deletes);
154
+ }
155
+ }
156
+ validatePlan(owner, plan) {
157
+ const header = plan.header;
158
+ if (!Array.isArray(plan.profileKey) || JSON.stringify(plan.profileKey) !== owner || !header || header.schemaVersion !== 1 || header.appId !== plan.profileKey[0] || header.profileId !== plan.profileKey[1])
159
+ throw new Error("READ_IDENTITY_MISMATCH");
160
+ }
161
+ async finish(plan) {
162
+ if (plan.versions !== undefined)
163
+ await this.db.table("readingVersions").put(plan.versions, plan.profileKey);
164
+ await this.db.table("readingProfiles").put(plan.header, plan.profileKey);
165
+ }
166
+ async commit(owner, operations, plan) {
167
+ this.check();
168
+ this.validatePlan(owner, plan);
169
+ this.knownOwners.delete(owner);
170
+ await this.cleanup(owner);
171
+ if (size(plan) > BYTES)
172
+ throw new Error("READ_STORAGE_RECORD_TOO_LARGE");
173
+ const id = crypto.randomUUID(), iterator = operations[Symbol.iterator]();
174
+ let next = iterator.next(), total = 0;
175
+ while (!next.done) {
176
+ const batch = [];
177
+ let bytes = 512;
178
+ while (!next.done && batch.length < LIMIT) {
179
+ const weight = size(next.value);
180
+ if (weight > BYTES - 512)
181
+ throw new Error("READ_STORAGE_RECORD_TOO_LARGE");
182
+ if (batch.length && bytes + weight > BYTES)
183
+ break;
184
+ bytes += weight;
185
+ batch.push(next.value);
186
+ next = iterator.next();
187
+ }
188
+ if (total === 0 && next.done) {
189
+ await this.db.transaction("rw", this.tables(), async () => { await this.checkLeases(); await this.apply(batch); await this.finish(plan); await this.checkLeases(true); });
190
+ this.knownOwners.add(owner);
191
+ return;
192
+ }
193
+ this.check();
194
+ await this.db.transaction("rw", this.rows, async () => { await this.checkLeases(); await this.rows.put({ owner, key: `${id}:${total++}`, operations: batch }); await this.checkLeases(true); });
195
+ await new Promise(resolve => setTimeout(resolve, 0));
196
+ }
197
+ if (!total) {
198
+ await this.db.transaction("rw", this.tables(), async () => { await this.checkLeases(); await this.finish(plan); await this.checkLeases(true); });
199
+ this.knownOwners.add(owner);
200
+ return;
201
+ }
202
+ await this.db.transaction("rw", this.rows, async () => {
203
+ await this.checkLeases();
204
+ await this.rows.put({ owner, key: `${id}:plan`, plan });
205
+ await this.rows.put({ owner, key: "active", active: { id, next: 0, total } });
206
+ await this.checkLeases(true);
207
+ });
208
+ await this.recover(owner);
209
+ }
210
+ }
@@ -110,7 +110,7 @@ export interface ReadIndexPersistence {
110
110
  /** Transform and persist atomically; UI publishes only the committed result. */
111
111
  update(appId: string, profileId: string, transform: ReadIndexTransform, observation?: ReadObservationScope): Promise<ReadIndexState>;
112
112
  }
113
- /** Dexie preserves the legacy database/store and outbound [appId, profileId] keys. */
113
+ /** Preserves the database and logical keys; store renaming fences old format writers. */
114
114
  export declare class IndexedDbReadIndex implements ReadIndexPersistence {
115
115
  private storageScope?;
116
116
  private migrateLegacy;
@@ -121,10 +121,17 @@ export declare class IndexedDbReadIndex implements ReadIndexPersistence {
121
121
  private messageRows;
122
122
  private positionRows;
123
123
  private normalizedStates;
124
+ private orderedStates;
125
+ private legacyClean;
124
126
  private observationMessages;
125
127
  private versions;
126
128
  private closed;
129
+ private journal;
130
+ private factory;
131
+ private schemaReady;
132
+ private ownerTail;
127
133
  constructor(factory?: IDBFactory, name?: string, storageScope?: string | undefined, migrateLegacy?: boolean, metadataOnly?: boolean);
134
+ private owned;
128
135
  private transaction;
129
136
  private key;
130
137
  /** Keyset pages avoid materializing a second, unbounded array of durable rows.
@@ -133,9 +140,7 @@ export declare class IndexedDbReadIndex implements ReadIndexPersistence {
133
140
  private hydrate;
134
141
  private loadCurrent;
135
142
  private loadStored;
136
- /** Message bodies are immutable for local read observations: keep them out of
137
- * profile/cursor writes, and update only changed normalized rows on sync. */
138
- private persist;
143
+ private cleanupClaimedLegacy;
139
144
  load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
140
145
  loadVersioned(appId: string, profileId: string): Promise<{
141
146
  state: ReadIndexState | undefined;
@@ -145,6 +150,10 @@ export declare class IndexedDbReadIndex implements ReadIndexPersistence {
145
150
  invalidateTeam(appId: string, profileId: string, teamId: string, version: number, knownThreadIds?: readonly string[]): Promise<ReadIndexState>;
146
151
  private observe;
147
152
  update(appId: string, profileId: string, transform: ReadIndexTransform, observation?: ReadObservationScope): Promise<ReadIndexState>;
153
+ private updateOwned;
154
+ private writeState;
155
+ private cleanUnreachable;
156
+ private changes;
148
157
  close(): Promise<void>;
149
158
  }
150
159
  export declare function sourcesInConversation(state: ReadIndexState, conversationId: string): IndexedReadThread[];