@abraca/dabra 1.0.17 → 1.0.19
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/dist/abracadabra-provider.cjs +46 -4
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +46 -4
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/package.json +1 -1
- package/src/AbracadabraProvider.ts +15 -1
- package/src/BackgroundSyncManager.ts +69 -10
- package/src/OfflineStore.ts +7 -3
package/dist/index.d.ts
CHANGED
|
@@ -699,7 +699,7 @@ declare class AbracadabraProvider extends AbracadabraBaseProvider {
|
|
|
699
699
|
*/
|
|
700
700
|
loadChild(childId: string): Promise<AbracadabraProvider>;
|
|
701
701
|
private _doLoadChild;
|
|
702
|
-
|
|
702
|
+
unloadChild(childId: string): void;
|
|
703
703
|
/** Return all currently-loaded child providers. */
|
|
704
704
|
get children(): Map<string, AbracadabraProvider>;
|
|
705
705
|
/**
|
|
@@ -1647,6 +1647,10 @@ interface BackgroundSyncManagerOptions {
|
|
|
1647
1647
|
syncTimeout?: number;
|
|
1648
1648
|
/** Pre-cache file blobs after syncing a doc. Default: true. */
|
|
1649
1649
|
prefetchFiles?: boolean;
|
|
1650
|
+
/** Delay (ms) between starting each doc sync to avoid server pressure. Default: 50. */
|
|
1651
|
+
throttleMs?: number;
|
|
1652
|
+
/** Max retries for failed docs within a single syncAll() run. Default: 2. */
|
|
1653
|
+
maxRetries?: number;
|
|
1650
1654
|
}
|
|
1651
1655
|
declare class BackgroundSyncManager extends EventEmitter {
|
|
1652
1656
|
private readonly rootProvider;
|
|
@@ -1692,6 +1696,7 @@ declare class BackgroundSyncManager extends EventEmitter {
|
|
|
1692
1696
|
* 3. Errored docs last
|
|
1693
1697
|
*/
|
|
1694
1698
|
private _buildQueue;
|
|
1699
|
+
/** Returns true on success (or skip), false on error. */
|
|
1695
1700
|
private _syncWithSemaphore;
|
|
1696
1701
|
private _doSyncDoc;
|
|
1697
1702
|
private _syncNonE2EDoc;
|
package/package.json
CHANGED
|
@@ -497,7 +497,7 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
|
|
|
497
497
|
return childProvider;
|
|
498
498
|
}
|
|
499
499
|
|
|
500
|
-
|
|
500
|
+
unloadChild(childId: string) {
|
|
501
501
|
const provider = this.childProviders.get(childId);
|
|
502
502
|
if (provider) {
|
|
503
503
|
provider.destroy();
|
|
@@ -584,11 +584,25 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
|
|
|
584
584
|
override destroy() {
|
|
585
585
|
this.document.off("subdocs", this.boundHandleYSubdocsChange);
|
|
586
586
|
|
|
587
|
+
// Collect child IDs before destroying — detach() may skip cleanup if
|
|
588
|
+
// a new provider already overwrote the slot during teardown/re-init races.
|
|
589
|
+
const childIds = [...this.childProviders.keys()];
|
|
590
|
+
|
|
587
591
|
for (const provider of this.childProviders.values()) {
|
|
588
592
|
provider.destroy();
|
|
589
593
|
}
|
|
590
594
|
this.childProviders.clear();
|
|
591
595
|
|
|
596
|
+
// Force-clear any stale providerMap entries for our children.
|
|
597
|
+
// detach() only removes if current === provider, so orphans can remain
|
|
598
|
+
// when a new provider attached before the old one was destroyed.
|
|
599
|
+
const wsProviderMap = this.configuration.websocketProvider?.configuration?.providerMap;
|
|
600
|
+
if (wsProviderMap) {
|
|
601
|
+
for (const childId of childIds) {
|
|
602
|
+
wsProviderMap.delete(childId);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
592
606
|
this.offlineStore?.destroy();
|
|
593
607
|
this.offlineStore = null;
|
|
594
608
|
|
|
@@ -38,6 +38,10 @@ export interface BackgroundSyncManagerOptions {
|
|
|
38
38
|
syncTimeout?: number;
|
|
39
39
|
/** Pre-cache file blobs after syncing a doc. Default: true. */
|
|
40
40
|
prefetchFiles?: boolean;
|
|
41
|
+
/** Delay (ms) between starting each doc sync to avoid server pressure. Default: 50. */
|
|
42
|
+
throttleMs?: number;
|
|
43
|
+
/** Max retries for failed docs within a single syncAll() run. Default: 2. */
|
|
44
|
+
maxRetries?: number;
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
/**
|
|
@@ -96,6 +100,8 @@ export class BackgroundSyncManager extends EventEmitter {
|
|
|
96
100
|
concurrency: opts?.concurrency ?? 2,
|
|
97
101
|
syncTimeout: opts?.syncTimeout ?? 15_000,
|
|
98
102
|
prefetchFiles: opts?.prefetchFiles ?? true,
|
|
103
|
+
throttleMs: opts?.throttleMs ?? 50,
|
|
104
|
+
maxRetries: opts?.maxRetries ?? 2,
|
|
99
105
|
};
|
|
100
106
|
|
|
101
107
|
// Derive server origin from client URL for IDB namespacing
|
|
@@ -155,12 +161,54 @@ export class BackgroundSyncManager extends EventEmitter {
|
|
|
155
161
|
// Build the priority queue
|
|
156
162
|
const queue = this._buildQueue(entries);
|
|
157
163
|
|
|
158
|
-
// Sync all docs
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
)
|
|
164
|
+
// Sync all docs with throttling: stagger starts to avoid server pressure.
|
|
165
|
+
// We use a feeding loop instead of Promise.all(queue.map(...)) so that
|
|
166
|
+
// each new doc start is spaced by throttleMs.
|
|
167
|
+
const failed: string[] = [];
|
|
168
|
+
let idx = 0;
|
|
169
|
+
const next = async (): Promise<void> => {
|
|
170
|
+
while (idx < queue.length) {
|
|
171
|
+
if (this._destroyed) return;
|
|
172
|
+
const docId = queue[idx++]!;
|
|
173
|
+
const updatedAt = updatedAtMap.get(docId) ?? 0;
|
|
174
|
+
const ok = await this._syncWithSemaphore(docId, updatedAt);
|
|
175
|
+
if (!ok) failed.push(docId);
|
|
176
|
+
// Throttle between starts
|
|
177
|
+
if (this.opts.throttleMs > 0 && idx < queue.length) {
|
|
178
|
+
await new Promise((r) => setTimeout(r, this.opts.throttleMs));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// Launch `concurrency` parallel workers feeding from the shared index.
|
|
184
|
+
const workers = Array.from({ length: this.opts.concurrency }, () => next());
|
|
185
|
+
await Promise.all(workers);
|
|
186
|
+
|
|
187
|
+
// Retry failed docs with increasing backoff.
|
|
188
|
+
for (let retry = 0; retry < this.opts.maxRetries && failed.length > 0; retry++) {
|
|
189
|
+
if (this._destroyed) return;
|
|
190
|
+
const batch = failed.splice(0, failed.length);
|
|
191
|
+
// Backoff: 2s, 4s, 8s...
|
|
192
|
+
const backoff = 2000 * 2 ** retry;
|
|
193
|
+
await new Promise((r) => setTimeout(r, backoff));
|
|
194
|
+
|
|
195
|
+
idx = 0;
|
|
196
|
+
const retryQueue = batch;
|
|
197
|
+
const retryNext = async (): Promise<void> => {
|
|
198
|
+
while (idx < retryQueue.length) {
|
|
199
|
+
if (this._destroyed) return;
|
|
200
|
+
const docId = retryQueue[idx++]!;
|
|
201
|
+
const updatedAt = updatedAtMap.get(docId) ?? 0;
|
|
202
|
+
const ok = await this._syncWithSemaphore(docId, updatedAt);
|
|
203
|
+
if (!ok) failed.push(docId);
|
|
204
|
+
if (this.opts.throttleMs > 0 && idx < retryQueue.length) {
|
|
205
|
+
await new Promise((r) => setTimeout(r, this.opts.throttleMs));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
const retryWorkers = Array.from({ length: this.opts.concurrency }, () => retryNext());
|
|
210
|
+
await Promise.all(retryWorkers);
|
|
211
|
+
}
|
|
164
212
|
}
|
|
165
213
|
|
|
166
214
|
/** Sync a single document by ID. */
|
|
@@ -273,11 +321,12 @@ export class BackgroundSyncManager extends EventEmitter {
|
|
|
273
321
|
return items.map((i) => i.docId);
|
|
274
322
|
}
|
|
275
323
|
|
|
324
|
+
/** Returns true on success (or skip), false on error. */
|
|
276
325
|
private async _syncWithSemaphore(
|
|
277
326
|
docId: string,
|
|
278
327
|
updatedAt: number,
|
|
279
|
-
): Promise<
|
|
280
|
-
if (this._destroyed) return;
|
|
328
|
+
): Promise<boolean> {
|
|
329
|
+
if (this._destroyed) return true;
|
|
281
330
|
|
|
282
331
|
// Skip if already synced and doc hasn't changed since last sync
|
|
283
332
|
const existing = this.syncStates.get(docId);
|
|
@@ -288,7 +337,7 @@ export class BackgroundSyncManager extends EventEmitter {
|
|
|
288
337
|
existing.lastSynced >= updatedAt
|
|
289
338
|
) {
|
|
290
339
|
this.emit("stateChanged", { docId, state: existing });
|
|
291
|
-
return;
|
|
340
|
+
return true;
|
|
292
341
|
}
|
|
293
342
|
|
|
294
343
|
await this.semaphore.acquire();
|
|
@@ -297,6 +346,7 @@ export class BackgroundSyncManager extends EventEmitter {
|
|
|
297
346
|
this.syncStates.set(docId, state);
|
|
298
347
|
await this.persistence.setState(state).catch(() => null);
|
|
299
348
|
this.emit("stateChanged", { docId, state });
|
|
349
|
+
return state.status !== "error";
|
|
300
350
|
} finally {
|
|
301
351
|
this.semaphore.release();
|
|
302
352
|
}
|
|
@@ -341,7 +391,9 @@ export class BackgroundSyncManager extends EventEmitter {
|
|
|
341
391
|
}
|
|
342
392
|
|
|
343
393
|
private async _syncNonE2EDoc(docId: string): Promise<DocSyncState> {
|
|
344
|
-
//
|
|
394
|
+
// Check if the provider already exists (user is viewing it) before loading.
|
|
395
|
+
const alreadyCached = this.rootProvider.children.has(docId);
|
|
396
|
+
|
|
345
397
|
const childProvider = await this.rootProvider.loadChild(docId);
|
|
346
398
|
|
|
347
399
|
// Wait for ready (offline snapshot loaded) then synced (server sync done)
|
|
@@ -353,6 +405,13 @@ export class BackgroundSyncManager extends EventEmitter {
|
|
|
353
405
|
this._prefetchDocFiles(docId, childProvider.document).catch(() => null);
|
|
354
406
|
}
|
|
355
407
|
|
|
408
|
+
// Release provider if it was created solely for background sync.
|
|
409
|
+
// This closes its IDB database, freeing the file descriptor.
|
|
410
|
+
// Providers that were already cached (user is viewing them) are kept alive.
|
|
411
|
+
if (!alreadyCached) {
|
|
412
|
+
this.rootProvider.unloadChild(docId);
|
|
413
|
+
}
|
|
414
|
+
|
|
356
415
|
return { docId, status: "synced", lastSynced: Date.now(), isE2E: false };
|
|
357
416
|
}
|
|
358
417
|
|
package/src/OfflineStore.ts
CHANGED
|
@@ -277,11 +277,15 @@ export class OfflineStore {
|
|
|
277
277
|
|
|
278
278
|
destroy() {
|
|
279
279
|
// Set the destroyed flag first so getDb() returns null for any new operations.
|
|
280
|
-
// Do NOT call db.close() here — in-flight IDB transactions hold their own
|
|
281
|
-
// reference to the database and closing it prematurely causes InvalidStateError.
|
|
282
|
-
// The DB will be closed and garbage-collected once all transactions complete.
|
|
283
280
|
this._destroyed = true;
|
|
281
|
+
const db = this.db;
|
|
284
282
|
this.db = null;
|
|
285
283
|
this.dbPromise = null;
|
|
284
|
+
// Close the IDB connection to free the file descriptor. Deferred to the
|
|
285
|
+
// next microtask so any in-flight transaction callbacks settle first.
|
|
286
|
+
// IDB spec: close() waits for running transactions to finish before closing.
|
|
287
|
+
if (db) {
|
|
288
|
+
Promise.resolve().then(() => db.close());
|
|
289
|
+
}
|
|
286
290
|
}
|
|
287
291
|
}
|