dexie-cloud-addon 4.4.11 → 4.4.12

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.
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * ==========================================================================
10
10
  *
11
- * Version 4.4.11, Sun Apr 19 2026
11
+ * Version 4.4.12, Mon May 25 2026
12
12
  *
13
13
  * https://dexie.org
14
14
  *
@@ -1623,192 +1623,145 @@ function findBlobRefs(obj) {
1623
1623
  }
1624
1624
 
1625
1625
  /**
1626
- * Eager Blob Downloader
1626
+ * BlobSavingQueue - Queues resolved blobs for saving back to IndexedDB.
1627
1627
  *
1628
- * Downloads unresolved blobs in the background when blobMode='eager'.
1629
- * Called after sync completes to prefetch blobs for offline access.
1628
+ * This is an internal collaborator of BlobDownloadTracker and is not
1629
+ * intended to be used directly by middleware or other code. See
1630
+ * BlobDownloadTracker.enqueueSave().
1630
1631
  *
1631
- * Progress is tracked automatically via liveQuery in blobProgress.ts
1632
- * no manual progress reporting needed here.
1633
- */
1634
- /**
1635
- * Download all unresolved blobs in the background.
1636
- *
1637
- * This is called when blobMode='eager' (default) after sync completes.
1638
- * BlobRef URLs are signed (SAS tokens) so no auth header needed.
1632
+ * Uses setTimeout(fn, 0) instead of queueMicrotask to completely isolate
1633
+ * from Dexie's Promise.PSD context. This prevents the save operation
1634
+ * from inheriting any ongoing transaction.
1639
1635
  *
1640
- * Each blob is saved atomically using Table.update() to avoid race conditions.
1636
+ * Each blob is saved atomically using downCore transaction with the specific
1637
+ * keyPath to avoid race conditions with other property changes.
1641
1638
  */
1642
- function downloadUnresolvedBlobs(db, downloading$, signal) {
1643
- return __awaiter(this, void 0, void 0, function* () {
1644
- var _a;
1645
- const debugLog = (msg) => console.debug(`[dexie-cloud] ${msg}`);
1646
- debugLog('Eager download: Starting...');
1647
- // Scan for unresolved blobs
1648
- const syncedTables = getSyncableTables(db);
1649
- let hasWork = false;
1650
- for (const table of syncedTables) {
1651
- try {
1652
- const hasIndex = !!table.schema.idxByName['_hasBlobRefs'];
1653
- if (!hasIndex)
1654
- continue;
1655
- const count = yield table.where('_hasBlobRefs').equals(1).count();
1656
- if (count > 0) {
1657
- hasWork = true;
1658
- break;
1659
- }
1660
- }
1661
- catch (_b) {
1662
- // skip
1663
- }
1639
+ class BlobSavingQueue {
1640
+ constructor(db, onPersisted) {
1641
+ this.queue = [];
1642
+ this.isProcessing = false;
1643
+ this.drainResolvers = [];
1644
+ this.db = db;
1645
+ this.onPersisted = onPersisted;
1646
+ }
1647
+ /**
1648
+ * Queue a resolved blob for saving.
1649
+ * Only the specific blob property will be updated atomically.
1650
+ */
1651
+ saveBlobs(tableName, primaryKey, resolvedBlobs) {
1652
+ this.queue.push({
1653
+ tableName,
1654
+ primaryKey,
1655
+ resolvedBlobs,
1656
+ });
1657
+ this.startConsumer();
1658
+ }
1659
+ /**
1660
+ * Returns a promise that resolves when the queue is empty AND no item
1661
+ * is currently being processed. Used by callers that need to know when
1662
+ * all previously enqueued saves have been persisted to IndexedDB before
1663
+ * making decisions based on the on-disk state (e.g., the eager blob
1664
+ * downloader looping over `_hasBlobRefs=1` rows in chunks).
1665
+ *
1666
+ * Note: New work enqueued AFTER drain() is called does NOT extend the
1667
+ * wait. Callers that race against concurrent producers should treat the
1668
+ * returned promise as "queue was empty at some point after this call".
1669
+ */
1670
+ drain() {
1671
+ if (!this.isProcessing && this.queue.length === 0) {
1672
+ return Promise.resolve();
1664
1673
  }
1665
- if (!hasWork) {
1666
- debugLog('Eager download: No blobs remaining, exiting');
1674
+ return new Promise((resolve) => {
1675
+ this.drainResolvers.push(resolve);
1676
+ });
1677
+ }
1678
+ /**
1679
+ * Start the consumer if not already processing.
1680
+ * Uses setTimeout(fn, 0) to completely break out of any
1681
+ * Dexie transaction context (Promise.PSD).
1682
+ */
1683
+ startConsumer() {
1684
+ if (this.isProcessing)
1667
1685
  return;
1668
- }
1669
- setDownloadingState(downloading$, true);
1670
- try {
1671
- debugLog(`Eager download: Found ${syncedTables.length} syncable tables: ${syncedTables.map((t) => t.name).join(', ')}`);
1672
- for (const table of syncedTables) {
1673
- if (signal === null || signal === void 0 ? void 0 : signal.aborted)
1674
- ;
1675
- try {
1676
- // Check if table has _hasBlobRefs index
1677
- const hasIndex = table.schema.indexes.some((idx) => idx.name === '_hasBlobRefs');
1678
- if (!hasIndex)
1679
- continue;
1680
- // Query objects with _hasBlobRefs marker
1681
- const unresolvedObjects = yield table
1682
- .where('_hasBlobRefs')
1683
- .equals(1)
1684
- .toArray();
1685
- debugLog(`Eager download: Table ${table.name} has ${unresolvedObjects.length} unresolved objects`);
1686
- const databaseUrl = (_a = db.cloud.options) === null || _a === void 0 ? void 0 : _a.databaseUrl;
1687
- if (!databaseUrl)
1688
- throw new Error('Database URL is required to download blobs');
1689
- // Download up to MAX_CONCURRENT blobs in parallel
1690
- const MAX_CONCURRENT = 6;
1691
- const primaryKey = table.schema.primKey;
1692
- // Filter to actionable objects first
1693
- const pending = unresolvedObjects.filter((obj) => {
1694
- if (!hasUnresolvedBlobRefs(obj))
1695
- return false;
1696
- const key = primaryKey.keyPath
1697
- ? Dexie.getByKeyPath(obj, primaryKey.keyPath)
1698
- : undefined;
1699
- return key !== undefined;
1700
- });
1701
- // Process in parallel with concurrency limit
1702
- let i = 0;
1703
- const runNext = () => __awaiter(this, void 0, void 0, function* () {
1704
- while (i < pending.length) {
1705
- if (signal === null || signal === void 0 ? void 0 : signal.aborted)
1706
- ;
1707
- const obj = pending[i++];
1708
- const key = Dexie.getByKeyPath(obj, primaryKey.keyPath);
1709
- try {
1710
- // Refresh token per object — cheap (returns cached) but ensures
1711
- // we pick up renewed tokens during long download sessions.
1712
- const resolvedBlobs = [];
1713
- yield resolveAllBlobRefs(obj, databaseUrl, resolvedBlobs, '', new WeakMap(), db.blobDownloadTracker);
1714
- const updateSpec = {
1715
- _hasBlobRefs: undefined,
1716
- };
1717
- for (const blob of resolvedBlobs) {
1718
- updateSpec[blob.keyPath] = blob.data;
1719
- }
1720
- debugLog(`Eager download: Updating ${table.name}:${key} with ${resolvedBlobs.length} blobs`);
1721
- yield table.update(key, updateSpec);
1722
- // liveQuery in blobProgress.ts auto-detects this change
1723
- }
1724
- catch (err) {
1725
- console.error(`Failed to download blobs for ${table.name}:${key}:`, err);
1726
- }
1727
- }
1728
- });
1729
- // Launch up to MAX_CONCURRENT workers
1730
- const workers = [];
1731
- for (let w = 0; w < Math.min(MAX_CONCURRENT, pending.length); w++) {
1732
- workers.push(runNext());
1733
- }
1734
- yield Promise.all(workers);
1735
- }
1736
- catch (err) {
1737
- // Table might not have _hasBlobRefs index or other issues - skip silently
1738
- }
1739
- }
1740
- }
1741
- finally {
1742
- setDownloadingState(downloading$, false);
1743
- }
1744
- });
1745
- }
1746
-
1747
- //const hasSW = 'serviceWorker' in navigator;
1748
- let hasComplainedAboutSyncEvent = false;
1749
- function registerSyncEvent(db, purpose) {
1750
- return __awaiter(this, void 0, void 0, function* () {
1751
- try {
1752
- // Send sync event to SW:
1753
- const sw = yield navigator.serviceWorker.ready;
1754
- if (purpose === 'push' && sw.sync) {
1755
- yield sw.sync.register(`dexie-cloud:${db.name}`);
1756
- }
1757
- if (sw.active) {
1758
- // Use postMessage for pull syncs and for browsers not supporting sync event (Firefox, Safari).
1759
- // Also chromium based browsers with sw.sync as a fallback for sleepy sync events not taking action for a while.
1760
- sw.active.postMessage({
1761
- type: 'dexie-cloud-sync',
1762
- dbName: db.name,
1763
- purpose,
1764
- });
1765
- }
1766
- else {
1767
- throw new Error(`Failed to trigger sync - there's no active service worker`);
1686
+ this.isProcessing = true;
1687
+ // Use setTimeout to completely isolate from Dexie's PSD context
1688
+ // queueMicrotask would risk inheriting the current transaction
1689
+ setTimeout(() => {
1690
+ this.processQueue();
1691
+ }, 0);
1692
+ }
1693
+ /**
1694
+ * Process all queued blobs.
1695
+ * Runs in a completely isolated context (no inherited transaction).
1696
+ * Uses atomic updates to avoid race conditions.
1697
+ */
1698
+ processQueue() {
1699
+ const item = this.queue.shift();
1700
+ if (!item) {
1701
+ this.isProcessing = false;
1702
+ // Fire any pending drain() waiters. New saveBlobs() calls that
1703
+ // arrive after this point will start a fresh processing cycle
1704
+ // and have their own drain() semantics.
1705
+ const resolvers = this.drainResolvers;
1706
+ if (resolvers.length > 0) {
1707
+ this.drainResolvers = [];
1708
+ for (const resolve of resolvers)
1709
+ resolve();
1768
1710
  }
1769
1711
  return;
1770
1712
  }
1771
- catch (e) {
1772
- if (!hasComplainedAboutSyncEvent) {
1773
- console.debug(`Dexie Cloud: Could not register sync event`, e);
1774
- hasComplainedAboutSyncEvent = true;
1713
+ // Atomic update of just the blob property
1714
+ this.db
1715
+ .transaction('rw', item.tableName, (tx) => {
1716
+ const trans = tx.idbtrans;
1717
+ trans.disableChangeTracking = true; // Don't regard this as a change for sync purposes
1718
+ trans.disableAccessControl = true; // Bypass any access control checks since this is an internal operation
1719
+ trans.disableBlobResolve = true; // Custom flag to skip blob resolve middleware during this transaction
1720
+ const updateSpec = {};
1721
+ for (const blob of item.resolvedBlobs) {
1722
+ updateSpec[blob.keyPath] = blob.data;
1775
1723
  }
1776
- }
1777
- });
1778
- }
1779
- function registerPeriodicSyncEvent(db) {
1780
- return __awaiter(this, void 0, void 0, function* () {
1781
- var _a;
1782
- try {
1783
- // Register periodicSync event to SW:
1784
- // @ts-ignore
1785
- const { periodicSync } = yield navigator.serviceWorker.ready;
1786
- if (periodicSync) {
1787
- try {
1788
- yield periodicSync.register(`dexie-cloud:${db.name}`, (_a = db.cloud.options) === null || _a === void 0 ? void 0 : _a.periodicSync);
1789
- console.debug(`Dexie Cloud: Successfully registered periodicsync event for ${db.name}`);
1790
- }
1791
- catch (e) {
1792
- console.debug(`Dexie Cloud: Failed to register periodic sync. Your PWA must be installed to allow background sync.`, e);
1724
+ tx.table(item.tableName).update(item.primaryKey, (obj) => {
1725
+ // Check that object still has the same unresolved blob refs before applying update (i.e. it hasn't been modified since we read it)
1726
+ for (const blob of item.resolvedBlobs) {
1727
+ // Verify atomicity - none of the blob properties has been modified since we read it. If any of them was modified, skip updating this item to avoid overwriting user changes.
1728
+ const currentValue = Dexie.getByKeyPath(obj, blob.keyPath);
1729
+ if (currentValue === undefined) {
1730
+ // Blob property was removed - skip updating this blob
1731
+ continue;
1732
+ }
1733
+ if (!isBlobRef(currentValue)) {
1734
+ // Blob property was modified to a non-blob-ref value - skip updating this blob
1735
+ continue;
1736
+ }
1737
+ if (currentValue.ref !== blob.ref) {
1738
+ // Blob property was modified - skip updating this blob
1739
+ return; // Stop. Another items has been queued to fully fix the object.
1740
+ }
1741
+ Dexie.setByKeyPath(obj, blob.keyPath, blob.data);
1793
1742
  }
1794
- }
1795
- else {
1796
- console.debug(`Dexie Cloud: periodicSync not supported.`);
1797
- }
1798
- }
1799
- catch (e) {
1800
- console.debug(`Dexie Cloud: Could not register periodicSync for ${db.name}`, e);
1801
- }
1802
- });
1803
- }
1804
-
1805
- function triggerSync(db, purpose) {
1806
- if (db.cloud.usingServiceWorker) {
1807
- console.debug('registering sync event');
1808
- registerSyncEvent(db, purpose);
1809
- }
1810
- else {
1811
- db.localSyncEvent.next({ purpose });
1743
+ delete obj._hasBlobRefs; // Clear the _hasBlobRefs marker if all refs was resolved.
1744
+ });
1745
+ // Note: we intentionally do NOT clear trans.mutatedParts here.
1746
+ // Letting the normal mutation signal through means the
1747
+ // blobProgress liveQuery (and any user-defined liveQuery that
1748
+ // depends on the resolved fields) wakes up and reflects progress
1749
+ // as blobs land in IndexedDB.
1750
+ })
1751
+ .catch((error) => {
1752
+ console.error(`Error saving resolved blobs on ${item.tableName}:${item.primaryKey}:`, error);
1753
+ })
1754
+ .finally(() => {
1755
+ // At this point, the transaction has completed (either successfully or with error),
1756
+ // and the blobs have been saved (or failed to save).
1757
+ // Notify the owner (BlobDownloadTracker) so it can release the
1758
+ // in-flight download cache entries for these refs. The cache was
1759
+ // kept alive until now to maximize reuse while the blob was still
1760
+ // in-flight (downloading or queued for save).
1761
+ this.onPersisted(item.resolvedBlobs.map((b) => b.ref));
1762
+ // Process next item in the queue
1763
+ return this.processQueue();
1764
+ });
1812
1765
  }
1813
1766
  }
1814
1767
 
@@ -2264,62 +2217,454 @@ function userAuthenticate(context, fetchToken, userInteraction, hints) {
2264
2217
  });
2265
2218
  throw error;
2266
2219
  }
2267
- let message = `We're having a problem authenticating right now.`;
2268
- console.error(`Error authenticating`, error);
2269
- if (error instanceof TypeError) {
2270
- const isOffline = typeof navigator !== 'undefined' && !navigator.onLine;
2271
- if (isOffline) {
2272
- message = `You seem to be offline. Please connect to the internet and try again.`;
2273
- }
2274
- else if (typeof location !== 'undefined' &&
2275
- (Dexie.debug ||
2276
- location.hostname === 'localhost' ||
2277
- location.hostname === '127.0.0.1')) {
2278
- // The audience is most likely the developer. Suggest to whitelist the localhost origin:
2279
- const whitelistCommand = `npx dexie-cloud whitelist ${location.origin}`;
2280
- message = `Could not connect to server. Please verify that your origin '${location.origin}' is whitelisted using \`npx dexie-cloud whitelist\``;
2281
- yield alertUser(userInteraction, 'Authentication Failed', {
2282
- type: 'error',
2283
- messageCode: 'GENERIC_ERROR',
2284
- message,
2285
- messageParams: {},
2286
- copyText: whitelistCommand,
2287
- }).catch(() => { });
2220
+ let message = `We're having a problem authenticating right now.`;
2221
+ console.error(`Error authenticating`, error);
2222
+ if (error instanceof TypeError) {
2223
+ const isOffline = typeof navigator !== 'undefined' && !navigator.onLine;
2224
+ if (isOffline) {
2225
+ message = `You seem to be offline. Please connect to the internet and try again.`;
2226
+ }
2227
+ else if (typeof location !== 'undefined' &&
2228
+ (Dexie.debug ||
2229
+ location.hostname === 'localhost' ||
2230
+ location.hostname === '127.0.0.1')) {
2231
+ // The audience is most likely the developer. Suggest to whitelist the localhost origin:
2232
+ const whitelistCommand = `npx dexie-cloud whitelist ${location.origin}`;
2233
+ message = `Could not connect to server. Please verify that your origin '${location.origin}' is whitelisted using \`npx dexie-cloud whitelist\``;
2234
+ yield alertUser(userInteraction, 'Authentication Failed', {
2235
+ type: 'error',
2236
+ messageCode: 'GENERIC_ERROR',
2237
+ message,
2238
+ messageParams: {},
2239
+ copyText: whitelistCommand,
2240
+ }).catch(() => { });
2241
+ }
2242
+ else {
2243
+ message = `Could not connect to server. Please verify the connection.`;
2244
+ yield alertUser(userInteraction, 'Authentication Failed', {
2245
+ type: 'error',
2246
+ messageCode: 'GENERIC_ERROR',
2247
+ message,
2248
+ messageParams: {},
2249
+ }).catch(() => { });
2250
+ }
2251
+ }
2252
+ throw error;
2253
+ }
2254
+ });
2255
+ }
2256
+ function spkiToPEM(keydata) {
2257
+ const keydataB64 = b64encode(keydata);
2258
+ const keydataB64Pem = formatAsPem(keydataB64);
2259
+ return keydataB64Pem;
2260
+ }
2261
+ function formatAsPem(str) {
2262
+ let finalString = '-----BEGIN PUBLIC KEY-----\n';
2263
+ while (str.length > 0) {
2264
+ finalString += str.substring(0, 64) + '\n';
2265
+ str = str.substring(64);
2266
+ }
2267
+ finalString = finalString + '-----END PUBLIC KEY-----';
2268
+ return finalString;
2269
+ }
2270
+
2271
+ const wm$3 = new WeakMap();
2272
+ function loadCachedAccessToken(db) {
2273
+ var _a, _b, _c, _d;
2274
+ let cached = wm$3.get(db);
2275
+ if (cached && cached.expiration > Date.now() + 5 * MINUTES) {
2276
+ return Promise.resolve(cached.accessToken);
2277
+ }
2278
+ const currentUser = db.cloud.currentUser.value;
2279
+ if (currentUser &&
2280
+ currentUser.accessToken &&
2281
+ ((_b = (_a = currentUser.accessTokenExpiration) === null || _a === void 0 ? void 0 : _a.getTime()) !== null && _b !== void 0 ? _b : Infinity) >
2282
+ Date.now() + 5 * MINUTES) {
2283
+ wm$3.set(db, {
2284
+ accessToken: currentUser.accessToken,
2285
+ expiration: (_d = (_c = currentUser.accessTokenExpiration) === null || _c === void 0 ? void 0 : _c.getTime()) !== null && _d !== void 0 ? _d : Infinity,
2286
+ });
2287
+ return Promise.resolve(currentUser.accessToken);
2288
+ }
2289
+ // If the current user is not logged in (no isLoggedIn flag), there's no
2290
+ // token to load from the database — skip the Dexie.ignoreTransaction() call.
2291
+ // This avoids a crash in service worker context where Dexie's Promise zone
2292
+ // (PSD.transless.env) may be undefined when called from within an active
2293
+ // rw transaction (e.g. during applyServerChanges).
2294
+ if (!(currentUser === null || currentUser === void 0 ? void 0 : currentUser.isLoggedIn)) {
2295
+ return Promise.resolve(null);
2296
+ }
2297
+ return Dexie.ignoreTransaction(() => loadAccessToken(db).then((user) => {
2298
+ var _a, _b;
2299
+ if (user === null || user === void 0 ? void 0 : user.accessToken) {
2300
+ wm$3.set(db, {
2301
+ accessToken: user.accessToken,
2302
+ expiration: (_b = (_a = user.accessTokenExpiration) === null || _a === void 0 ? void 0 : _a.getTime()) !== null && _b !== void 0 ? _b : Infinity,
2303
+ });
2304
+ }
2305
+ return (user === null || user === void 0 ? void 0 : user.accessToken) || null;
2306
+ }));
2307
+ }
2308
+
2309
+ /**
2310
+ * Owns the full lifecycle of downloaded blobs:
2311
+ * 1. Deduplicates concurrent downloads for the same ref.
2312
+ * 2. Bounds the number of concurrent network fetches (MAX_CONCURRENT)
2313
+ * so that ad-hoc reads can't starve the HTTP connection pool. Calls
2314
+ * beyond the cap queue in FIFO order as slots free. The slot is held
2315
+ * only for the duration of the fetch — NOT until persistence — to
2316
+ * avoid deadlocks when a single object contains more blob refs than
2317
+ * MAX_CONCURRENT (a sequential resolver would otherwise hold every
2318
+ * slot itself while waiting for the next).
2319
+ * 3. Keeps the in-flight promise alive after the network fetch completes,
2320
+ * until the blob has been persisted back to IndexedDB. This way,
2321
+ * readers that ask for the same ref while it is queued for saving
2322
+ * can piggyback on the existing promise instead of refetching.
2323
+ * In-flight membership and slot ownership are independent: a piggyback
2324
+ * reader consumes neither a slot nor extra memory beyond the existing
2325
+ * cached Uint8Array.
2326
+ * 4. Persists resolved blobs via an internal BlobSavingQueue, and
2327
+ * releases the in-flight entry when persistence completes.
2328
+ *
2329
+ * Both the blob-resolve middleware and the eager blob downloader use this
2330
+ * tracker. Instantiate once per DexieCloudDB.
2331
+ */
2332
+ /**
2333
+ * Maximum number of concurrent blob fetches.
2334
+ *
2335
+ * Historically 6 to match the HTTP/1.1 same-origin connection cap that
2336
+ * browsers enforce. With HTTP/2 (the typical transport for Dexie Cloud
2337
+ * today) many streams multiplex over a single TCP connection, so the
2338
+ * old cap is overly conservative. 10 is a modest bump that still keeps
2339
+ * memory pressure (in-flight Uint8Arrays) and server load bounded.
2340
+ * Can be made configurable via DexieCloudOptions if a real need arises.
2341
+ */
2342
+ const MAX_CONCURRENT = 10;
2343
+ class BlobDownloadTracker {
2344
+ constructor(db) {
2345
+ this.inFlight = new Map();
2346
+ this.activeFetches = 0;
2347
+ this.waiting = [];
2348
+ this.db = db;
2349
+ this.savingQueue = new BlobSavingQueue(db, (refs) => {
2350
+ // Called by the queue when a save transaction has completed
2351
+ // (regardless of success). Drop the in-flight cache entries now —
2352
+ // any future reader will go through IndexedDB instead.
2353
+ for (const ref of refs) {
2354
+ this.inFlight.delete(ref);
2355
+ }
2356
+ });
2357
+ }
2358
+ /**
2359
+ * Download a blob, deduplicating concurrent requests for the same ref
2360
+ * and respecting the global fetch concurrency cap.
2361
+ *
2362
+ * Lifecycle:
2363
+ * - Slot is acquired before the fetch and released as soon as the
2364
+ * fetch settles (success or failure).
2365
+ * - The in-flight entry survives a successful fetch and lives on
2366
+ * until persistence completes (via enqueueSave) or releaseRefs
2367
+ * is called. On fetch failure, the entry is removed immediately
2368
+ * so a future call can retry.
2369
+ *
2370
+ * @param blobRef - The BlobRef to download
2371
+ * @param dbUrl - Base URL for the database (e.g., 'https://mydb.dexie.cloud')
2372
+ */
2373
+ download(blobRef, dbUrl) {
2374
+ let promise = this.inFlight.get(blobRef.ref);
2375
+ if (!promise) {
2376
+ promise = this.acquireSlot()
2377
+ .then(() => this.downloadBlob(blobRef, dbUrl).finally(() => this.releaseSlot()))
2378
+ .catch((err) => {
2379
+ // On error, remove immediately so a future call can retry.
2380
+ // (Slot already released by the .finally above.)
2381
+ this.inFlight.delete(blobRef.ref);
2382
+ throw err;
2383
+ });
2384
+ this.inFlight.set(blobRef.ref, promise);
2385
+ }
2386
+ return promise;
2387
+ }
2388
+ /**
2389
+ * Queue resolved blobs for persisting back to IndexedDB.
2390
+ * When the save transaction completes, the corresponding in-flight
2391
+ * entries are released.
2392
+ */
2393
+ enqueueSave(tableName, primaryKey, resolvedBlobs) {
2394
+ this.savingQueue.saveBlobs(tableName, primaryKey, resolvedBlobs);
2395
+ }
2396
+ /**
2397
+ * Wait until all previously enqueued saves have been persisted to
2398
+ * IndexedDB. Used by callers that need to make decisions based on
2399
+ * on-disk state — e.g., the eager downloader looping over rows with
2400
+ * `_hasBlobRefs=1` in chunks, where each iteration must see the
2401
+ * previous chunk's writes before re-querying.
2402
+ *
2403
+ * New saves enqueued AFTER drainPendingSaves() is called do NOT extend
2404
+ * the wait.
2405
+ */
2406
+ drainPendingSaves() {
2407
+ return this.savingQueue.drain();
2408
+ }
2409
+ /**
2410
+ * Release in-flight entries without going through the internal saving
2411
+ * queue. Used when the caller persists the blobs itself, or when no
2412
+ * primary key was available and the data won't be persisted at all.
2413
+ */
2414
+ releaseRefs(refs) {
2415
+ for (const ref of refs) {
2416
+ this.inFlight.delete(ref);
2417
+ }
2418
+ }
2419
+ acquireSlot() {
2420
+ if (this.activeFetches < MAX_CONCURRENT) {
2421
+ this.activeFetches++;
2422
+ return Promise.resolve();
2423
+ }
2424
+ return new Promise((resolve) => {
2425
+ this.waiting.push(() => {
2426
+ this.activeFetches++;
2427
+ resolve();
2428
+ });
2429
+ });
2430
+ }
2431
+ releaseSlot() {
2432
+ this.activeFetches--;
2433
+ const next = this.waiting.shift();
2434
+ if (next)
2435
+ next();
2436
+ }
2437
+ /**
2438
+ * Download blob data from server via proxy endpoint.
2439
+ * Uses auth header for authentication (same as sync).
2440
+ * When accessToken is null, the request is made without Authorization header —
2441
+ * this allows downloading blobs from public realms (rlm-public) for
2442
+ * unauthenticated users.
2443
+ *
2444
+ * @param blobRef - The BlobRef to download
2445
+ * @param dbUrl - Base URL for the database (e.g., 'https://mydb.dexie.cloud')
2446
+ */
2447
+ downloadBlob(blobRef, dbUrl) {
2448
+ return __awaiter(this, void 0, void 0, function* () {
2449
+ const accessToken = yield loadCachedAccessToken(this.db);
2450
+ const downloadUrl = `${dbUrl}/blob/${blobRef.ref}`;
2451
+ const headers = {};
2452
+ if (accessToken) {
2453
+ // accessToken may be null for anonymous/unauthenticated users.
2454
+ // Public realm blobs (rlm-public) are accessible without auth.
2455
+ // downloadBlob will omit the Authorization header when token is null.
2456
+ headers['Authorization'] = `Bearer ${accessToken}`;
2457
+ }
2458
+ // cache: 'no-store' prevents the browser from storing this response in its
2459
+ // HTTP cache. The server sets a long Expires/Cache-Control header on blob
2460
+ // responses (blobs are immutable and content-addressed), which would
2461
+ // otherwise cause the browser to keep a copy in its disk cache in addition
2462
+ // to the copy we persist to IndexedDB — doubling storage for every blob.
2463
+ // Since we always persist to IndexedDB and subsequent reads go through
2464
+ // IndexedDB (never re-fetch), the browser cache copy is pure overhead.
2465
+ const response = yield fetch(downloadUrl, { headers, cache: 'no-store' });
2466
+ if (!response.ok) {
2467
+ throw new Error(`Failed to download blob ${blobRef.ref}: ${response.status} ${response.statusText}`);
2468
+ }
2469
+ const arrayBuffer = yield response.arrayBuffer();
2470
+ return new Uint8Array(arrayBuffer);
2471
+ });
2472
+ }
2473
+ }
2474
+
2475
+ /**
2476
+ * Eager Blob Downloader
2477
+ *
2478
+ * Downloads unresolved blobs in the background when blobMode='eager'.
2479
+ * Called after sync completes to prefetch blobs for offline access.
2480
+ *
2481
+ * Strategy:
2482
+ * 1. Snapshot the primary keys of all rows currently flagged
2483
+ * `_hasBlobRefs=1` for each syncable table.
2484
+ * 2. Walk that key list in chunks via `bulkGet`. Each `bulkGet`
2485
+ * triggers the blob-resolve middleware, which does all the actual
2486
+ * work — downloading blobs (throttled and deduplicated by the
2487
+ * shared BlobDownloadTracker) and enqueueing them for persistence
2488
+ * via the internal save queue.
2489
+ *
2490
+ * This keeps a single, symmetric code path with normal application
2491
+ * reads, which is important when other middlewares are present
2492
+ * (e.g., a hypothetical encryption middleware): writes from the save
2493
+ * queue and reads from this loop both pass through the full middleware
2494
+ * stack, so on-disk representation stays consistent.
2495
+ *
2496
+ * Why a snapshot of primary keys (rather than re-querying the index)?
2497
+ * - Rows that get resolved by parallel application reads simply
2498
+ * disappear from the table contents we're about to re-fetch; the
2499
+ * middleware skips them since `_hasBlobRefs` is already cleared.
2500
+ * - Stuck rows (e.g., blob 404s) are naturally bypassed: we just
2501
+ * advance to the next chunk in the snapshot. No `seenKeys`
2502
+ * bookkeeping required.
2503
+ * - The snapshot is `string[]`-shaped for typical Dexie Cloud rows
2504
+ * (~36 bytes/UUID), so ~28K keys per MB. Acceptable for any
2505
+ * realistic dataset.
2506
+ *
2507
+ * Progress is tracked automatically via liveQuery in blobProgress.ts —
2508
+ * no manual progress reporting needed here.
2509
+ *
2510
+ * --- Throughput note ---
2511
+ * The chunk loop is sequential: bulkGet → wait for all downloads to
2512
+ * settle → next bulkGet. The save queue drains in the background and
2513
+ * does not block iteration (saves no longer need to be persisted before
2514
+ * the next iteration, since we don't re-query the index). For typical
2515
+ * blob sizes (10 KB – 10 MB) the network dominates total time. If
2516
+ * real-world profiling later shows the per-chunk fixed cost matters,
2517
+ * the next bulkGet could be kicked off in parallel with the current
2518
+ * one's middleware work — but we keep it simple until measurements
2519
+ * justify otherwise.
2520
+ */
2521
+ // One chunk = one full saturation of the tracker's concurrency semaphore.
2522
+ // Larger chunks would only buffer more downloaded Uint8Arrays in memory
2523
+ // while waiting for the save queue to persist them, without any throughput
2524
+ // benefit (the semaphore is the gate, not the bulkGet).
2525
+ const CHUNK_SIZE = MAX_CONCURRENT - 1; // Leave one slot for parallel app reads that might also trigger downloads
2526
+ /**
2527
+ * Download all unresolved blobs in the background.
2528
+ *
2529
+ * This is called when blobMode='eager' (default) after sync completes.
2530
+ */
2531
+ function downloadUnresolvedBlobs(db, downloading$, signal) {
2532
+ return __awaiter(this, void 0, void 0, function* () {
2533
+ const debugLog = (msg) => console.debug(`[dexie-cloud] ${msg}`);
2534
+ debugLog('Eager download: Starting...');
2535
+ const syncedTables = getSyncableTables(db).filter((t) => t.schema.indexes.some((idx) => idx.name === '_hasBlobRefs'));
2536
+ let started = false;
2537
+ let totalProcessed = 0;
2538
+ try {
2539
+ for (const table of syncedTables) {
2540
+ if (signal === null || signal === void 0 ? void 0 : signal.aborted)
2541
+ ;
2542
+ let keys;
2543
+ try {
2544
+ keys = yield table.where('_hasBlobRefs').equals(1).primaryKeys();
2545
+ }
2546
+ catch (err) {
2547
+ console.error(`Eager download: failed to list unresolved rows for ${table.name}:`, err);
2548
+ continue;
2549
+ }
2550
+ if (keys.length === 0)
2551
+ continue;
2552
+ if (!started) {
2553
+ setDownloadingState(downloading$, true);
2554
+ started = true;
2555
+ }
2556
+ debugLog(`Eager download: ${table.name} has ${keys.length} row(s)`);
2557
+ for (let i = 0; i < keys.length; i += CHUNK_SIZE) {
2558
+ if (signal === null || signal === void 0 ? void 0 : signal.aborted)
2559
+ ;
2560
+ const slice = keys.slice(i, i + CHUNK_SIZE);
2561
+ try {
2562
+ // bulkGet triggers the blob-resolve middleware for each row that
2563
+ // still has `_hasBlobRefs=1`. Rows already resolved by parallel
2564
+ // reads come back without the marker and the middleware no-ops.
2565
+ // Rows that have been deleted return `undefined` and are
2566
+ // likewise skipped.
2567
+ yield table.bulkGet(slice);
2568
+ }
2569
+ catch (err) {
2570
+ console.error(`Eager download: ${table.name} chunk failed:`, err);
2571
+ continue;
2572
+ }
2573
+ totalProcessed += slice.length;
2574
+ debugLog(`Eager download: ${table.name} ${Math.min(i + CHUNK_SIZE, keys.length)}/${keys.length}`);
2575
+ }
2576
+ }
2577
+ if (started) {
2578
+ // Make sure all middleware-enqueued saves have landed before we flip
2579
+ // `downloading$` to false — otherwise observers might see a "done"
2580
+ // signal while writes are still in flight.
2581
+ yield db.blobDownloadTracker.drainPendingSaves();
2582
+ debugLog(`Eager download: done (${totalProcessed} row(s) processed)`);
2583
+ }
2584
+ else {
2585
+ debugLog('Eager download: No blobs remaining, exiting');
2586
+ }
2587
+ }
2588
+ finally {
2589
+ if (started)
2590
+ setDownloadingState(downloading$, false);
2591
+ }
2592
+ });
2593
+ }
2594
+
2595
+ //const hasSW = 'serviceWorker' in navigator;
2596
+ let hasComplainedAboutSyncEvent = false;
2597
+ function registerSyncEvent(db, purpose) {
2598
+ return __awaiter(this, void 0, void 0, function* () {
2599
+ try {
2600
+ // Send sync event to SW:
2601
+ const sw = yield navigator.serviceWorker.ready;
2602
+ if (purpose === 'push' && sw.sync) {
2603
+ yield sw.sync.register(`dexie-cloud:${db.name}`);
2604
+ }
2605
+ if (sw.active) {
2606
+ // Use postMessage for pull syncs and for browsers not supporting sync event (Firefox, Safari).
2607
+ // Also chromium based browsers with sw.sync as a fallback for sleepy sync events not taking action for a while.
2608
+ sw.active.postMessage({
2609
+ type: 'dexie-cloud-sync',
2610
+ dbName: db.name,
2611
+ purpose,
2612
+ });
2613
+ }
2614
+ else {
2615
+ throw new Error(`Failed to trigger sync - there's no active service worker`);
2616
+ }
2617
+ return;
2618
+ }
2619
+ catch (e) {
2620
+ if (!hasComplainedAboutSyncEvent) {
2621
+ console.debug(`Dexie Cloud: Could not register sync event`, e);
2622
+ hasComplainedAboutSyncEvent = true;
2623
+ }
2624
+ }
2625
+ });
2626
+ }
2627
+ function registerPeriodicSyncEvent(db) {
2628
+ return __awaiter(this, void 0, void 0, function* () {
2629
+ var _a;
2630
+ try {
2631
+ // Register periodicSync event to SW:
2632
+ // @ts-ignore
2633
+ const { periodicSync } = yield navigator.serviceWorker.ready;
2634
+ if (periodicSync) {
2635
+ try {
2636
+ yield periodicSync.register(`dexie-cloud:${db.name}`, (_a = db.cloud.options) === null || _a === void 0 ? void 0 : _a.periodicSync);
2637
+ console.debug(`Dexie Cloud: Successfully registered periodicsync event for ${db.name}`);
2288
2638
  }
2289
- else {
2290
- message = `Could not connect to server. Please verify the connection.`;
2291
- yield alertUser(userInteraction, 'Authentication Failed', {
2292
- type: 'error',
2293
- messageCode: 'GENERIC_ERROR',
2294
- message,
2295
- messageParams: {},
2296
- }).catch(() => { });
2639
+ catch (e) {
2640
+ console.debug(`Dexie Cloud: Failed to register periodic sync. Your PWA must be installed to allow background sync.`, e);
2297
2641
  }
2298
2642
  }
2299
- throw error;
2643
+ else {
2644
+ console.debug(`Dexie Cloud: periodicSync not supported.`);
2645
+ }
2646
+ }
2647
+ catch (e) {
2648
+ console.debug(`Dexie Cloud: Could not register periodicSync for ${db.name}`, e);
2300
2649
  }
2301
2650
  });
2302
2651
  }
2303
- function spkiToPEM(keydata) {
2304
- const keydataB64 = b64encode(keydata);
2305
- const keydataB64Pem = formatAsPem(keydataB64);
2306
- return keydataB64Pem;
2307
- }
2308
- function formatAsPem(str) {
2309
- let finalString = '-----BEGIN PUBLIC KEY-----\n';
2310
- while (str.length > 0) {
2311
- finalString += str.substring(0, 64) + '\n';
2312
- str = str.substring(64);
2652
+
2653
+ function triggerSync(db, purpose) {
2654
+ if (db.cloud.usingServiceWorker) {
2655
+ console.debug('registering sync event');
2656
+ registerSyncEvent(db, purpose);
2657
+ }
2658
+ else {
2659
+ db.localSyncEvent.next({ purpose });
2313
2660
  }
2314
- finalString = finalString + '-----END PUBLIC KEY-----';
2315
- return finalString;
2316
2661
  }
2317
2662
 
2318
2663
  // Emulate true-private property db. Why? So it's not stored in DB.
2319
- const wm$3 = new WeakMap();
2664
+ const wm$2 = new WeakMap();
2320
2665
  class AuthPersistedContext {
2321
2666
  constructor(db, userLogin) {
2322
- wm$3.set(this, db);
2667
+ wm$2.set(this, db);
2323
2668
  Object.assign(this, userLogin);
2324
2669
  }
2325
2670
  static load(db, userId) {
@@ -2336,7 +2681,7 @@ class AuthPersistedContext {
2336
2681
  }
2337
2682
  save() {
2338
2683
  return __awaiter(this, void 0, void 0, function* () {
2339
- const db = wm$3.get(this);
2684
+ const db = wm$2.get(this);
2340
2685
  db.table('$logins').put(this);
2341
2686
  });
2342
2687
  }
@@ -4551,44 +4896,6 @@ function downloadYDocsFromServer(db_1, databaseUrl_1, _a) {
4551
4896
  });
4552
4897
  }
4553
4898
 
4554
- const wm$2 = new WeakMap();
4555
- function loadCachedAccessToken(db) {
4556
- var _a, _b, _c, _d;
4557
- let cached = wm$2.get(db);
4558
- if (cached && cached.expiration > Date.now() + 5 * MINUTES) {
4559
- return Promise.resolve(cached.accessToken);
4560
- }
4561
- const currentUser = db.cloud.currentUser.value;
4562
- if (currentUser &&
4563
- currentUser.accessToken &&
4564
- ((_b = (_a = currentUser.accessTokenExpiration) === null || _a === void 0 ? void 0 : _a.getTime()) !== null && _b !== void 0 ? _b : Infinity) >
4565
- Date.now() + 5 * MINUTES) {
4566
- wm$2.set(db, {
4567
- accessToken: currentUser.accessToken,
4568
- expiration: (_d = (_c = currentUser.accessTokenExpiration) === null || _c === void 0 ? void 0 : _c.getTime()) !== null && _d !== void 0 ? _d : Infinity,
4569
- });
4570
- return Promise.resolve(currentUser.accessToken);
4571
- }
4572
- // If the current user is not logged in (no isLoggedIn flag), there's no
4573
- // token to load from the database — skip the Dexie.ignoreTransaction() call.
4574
- // This avoids a crash in service worker context where Dexie's Promise zone
4575
- // (PSD.transless.env) may be undefined when called from within an active
4576
- // rw transaction (e.g. during applyServerChanges).
4577
- if (!(currentUser === null || currentUser === void 0 ? void 0 : currentUser.isLoggedIn)) {
4578
- return Promise.resolve(null);
4579
- }
4580
- return Dexie.ignoreTransaction(() => loadAccessToken(db).then((user) => {
4581
- var _a, _b;
4582
- if (user === null || user === void 0 ? void 0 : user.accessToken) {
4583
- wm$2.set(db, {
4584
- accessToken: user.accessToken,
4585
- expiration: (_b = (_a = user.accessTokenExpiration) === null || _a === void 0 ? void 0 : _a.getTime()) !== null && _b !== void 0 ? _b : Infinity,
4586
- });
4587
- }
4588
- return (user === null || user === void 0 ? void 0 : user.accessToken) || null;
4589
- }));
4590
- }
4591
-
4592
4899
  const CURRENT_SYNC_WORKER = 'currentSyncWorker';
4593
4900
  function sync(db, options, schema, syncOptions) {
4594
4901
  return _sync(db, options, schema, syncOptions)
@@ -5132,71 +5439,6 @@ function MessagesFromServerConsumer(db) {
5132
5439
  };
5133
5440
  }
5134
5441
 
5135
- /**
5136
- * Deduplicates in-flight blob downloads.
5137
- *
5138
- * Both the blob-resolve middleware and the eager blob downloader may
5139
- * try to fetch the same blob concurrently. This tracker ensures each
5140
- * unique blob ref is only downloaded once — subsequent requests for
5141
- * the same ref piggyback on the existing promise.
5142
- *
5143
- * Instantiate once per DexieCloudDB.
5144
- */
5145
- class BlobDownloadTracker {
5146
- constructor(db) {
5147
- this.inFlight = new Map();
5148
- this.db = db;
5149
- }
5150
- /**
5151
- * Download a blob, deduplicating concurrent requests for the same ref.
5152
- *
5153
- * @param blobRef - The BlobRef to download
5154
- * @param dbUrl - Base URL for the database (e.g., 'https://mydb.dexie.cloud')
5155
- */
5156
- download(blobRef, dbUrl) {
5157
- let promise = this.inFlight.get(blobRef.ref);
5158
- if (!promise) {
5159
- promise = loadCachedAccessToken(this.db)
5160
- .then((accessToken) => {
5161
- // accessToken may be null for anonymous/unauthenticated users.
5162
- // Public realm blobs (rlm-public) are accessible without auth.
5163
- // downloadBlob will omit the Authorization header when token is null.
5164
- return downloadBlob(blobRef, dbUrl, accessToken);
5165
- })
5166
- .finally(() => this.inFlight.delete(blobRef.ref));
5167
- // When the promise settles (either fulfilled or rejected), remove it from the in-flight map
5168
- this.inFlight.set(blobRef.ref, promise);
5169
- }
5170
- return promise;
5171
- }
5172
- }
5173
- /**
5174
- * Download blob data from server via proxy endpoint.
5175
- * Uses auth header for authentication (same as sync).
5176
- * When accessToken is null, the request is made without Authorization header —
5177
- * this allows downloading blobs from public realms (rlm-public) for
5178
- * unauthenticated users.
5179
- *
5180
- * @param blobRef - The BlobRef to download
5181
- * @param dbUrl - Base URL for the database (e.g., 'https://mydb.dexie.cloud')
5182
- * @param accessToken - Access token for authentication, or null for anonymous access
5183
- */
5184
- function downloadBlob(blobRef, dbUrl, accessToken) {
5185
- return __awaiter(this, void 0, void 0, function* () {
5186
- const downloadUrl = `${dbUrl}/blob/${blobRef.ref}`;
5187
- const headers = {};
5188
- if (accessToken) {
5189
- headers['Authorization'] = `Bearer ${accessToken}`;
5190
- }
5191
- const response = yield fetch(downloadUrl, { headers });
5192
- if (!response.ok) {
5193
- throw new Error(`Failed to download blob ${blobRef.ref}: ${response.status} ${response.statusText}`);
5194
- }
5195
- const arrayBuffer = yield response.arrayBuffer();
5196
- return new Uint8Array(arrayBuffer);
5197
- });
5198
- }
5199
-
5200
5442
  const wm$1 = new WeakMap();
5201
5443
  const DEXIE_CLOUD_SCHEMA = {
5202
5444
  members: '@id, [userId+realmId], [email+realmId], realmId',
@@ -5992,99 +6234,6 @@ function createMutationTrackingMiddleware({ currentUserObservable, db, }) {
5992
6234
  };
5993
6235
  }
5994
6236
 
5995
- /**
5996
- * BlobSavingQueue - Queues resolved blobs for saving back to IndexedDB
5997
- *
5998
- * Uses setTimeout(fn, 0) instead of queueMicrotask to completely isolate
5999
- * from Dexie's Promise.PSD context. This prevents the save operation
6000
- * from inheriting any ongoing transaction.
6001
- *
6002
- * Each blob is saved atomically using downCore transaction with the specific
6003
- * keyPath to avoid race conditions with other property changes.
6004
- */
6005
- class BlobSavingQueue {
6006
- constructor(db) {
6007
- this.queue = [];
6008
- this.isProcessing = false;
6009
- this.db = db;
6010
- }
6011
- /**
6012
- * Queue a resolved blob for saving.
6013
- * Only the specific blob property will be updated atomically.
6014
- */
6015
- saveBlobs(tableName, primaryKey, resolvedBlobs) {
6016
- this.queue.push({ tableName, primaryKey, resolvedBlobs });
6017
- this.startConsumer();
6018
- }
6019
- /**
6020
- * Start the consumer if not already processing.
6021
- * Uses setTimeout(fn, 0) to completely break out of any
6022
- * Dexie transaction context (Promise.PSD).
6023
- */
6024
- startConsumer() {
6025
- if (this.isProcessing)
6026
- return;
6027
- this.isProcessing = true;
6028
- // Use setTimeout to completely isolate from Dexie's PSD context
6029
- // queueMicrotask would risk inheriting the current transaction
6030
- setTimeout(() => {
6031
- this.processQueue();
6032
- }, 0);
6033
- }
6034
- /**
6035
- * Process all queued blobs.
6036
- * Runs in a completely isolated context (no inherited transaction).
6037
- * Uses atomic updates to avoid race conditions.
6038
- */
6039
- processQueue() {
6040
- const item = this.queue.shift();
6041
- if (!item) {
6042
- this.isProcessing = false;
6043
- return;
6044
- }
6045
- // Atomic update of just the blob property
6046
- this.db
6047
- .transaction('rw', item.tableName, (tx) => {
6048
- const trans = tx.idbtrans;
6049
- trans.disableChangeTracking = true; // Don't regard this as a change for sync purposes
6050
- trans.disableAccessControl = true; // Bypass any access control checks since this is an internal operation
6051
- trans.disableBlobResolve = true; // Custom flag to skip blob resolve middleware during this transaction
6052
- const updateSpec = {};
6053
- for (const blob of item.resolvedBlobs) {
6054
- updateSpec[blob.keyPath] = blob.data;
6055
- }
6056
- tx.table(item.tableName).update(item.primaryKey, (obj) => {
6057
- // Check that object still has the same unresolved blob refs before applying update (i.e. it hasn't been modified since we read it)
6058
- for (const blob of item.resolvedBlobs) {
6059
- // Verify atomicity - none of the blob properties has been modified since we read it. If any of them was modified, skip updating this item to avoid overwriting user changes.
6060
- const currentValue = Dexie.getByKeyPath(obj, blob.keyPath);
6061
- if (currentValue === undefined) {
6062
- // Blob property was removed - skip updating this blob
6063
- continue;
6064
- }
6065
- if (!isBlobRef(currentValue)) {
6066
- // Blob property was modified to a non-blob-ref value - skip updating this blob
6067
- continue;
6068
- }
6069
- if (currentValue.ref !== blob.ref) {
6070
- // Blob property was modified - skip updating this blob
6071
- return; // Stop. Another items has been queued to fully fix the object.
6072
- }
6073
- Dexie.setByKeyPath(obj, blob.keyPath, blob.data);
6074
- }
6075
- delete obj._hasBlobRefs; // Clear the _hasBlobRefs marker if all refs was resolved.
6076
- });
6077
- })
6078
- .catch((error) => {
6079
- console.error(`Error saving resolved blobs on ${item.tableName}:${item.primaryKey}:`, error);
6080
- })
6081
- .finally(() => {
6082
- // Process next item in the queue
6083
- return this.processQueue();
6084
- });
6085
- }
6086
- }
6087
-
6088
6237
  /**
6089
6238
  * DBCore Middleware for resolving BlobRefs on read
6090
6239
  *
@@ -6095,10 +6244,11 @@ class BlobSavingQueue {
6095
6244
  * Uses Dexie.waitFor() only for explicit rw transactions to keep them alive.
6096
6245
  * For readonly or implicit transactions, resolves directly (no waitFor needed).
6097
6246
  *
6098
- * Resolved blobs are queued for saving via BlobSavingQueue, which uses
6099
- * setTimeout(fn, 0) to completely isolate from Dexie's transaction context.
6100
- * Each blob is saved atomically using Table.update() with its keyPath to
6101
- * avoid race conditions with other property changes.
6247
+ * Resolved blobs are persisted via db.blobDownloadTracker.enqueueSave(),
6248
+ * which internally uses a queue that runs in a fresh JS task to completely
6249
+ * isolate from Dexie's transaction context. Each blob is saved atomically
6250
+ * using Table.update() with its keyPath to avoid race conditions with other
6251
+ * property changes.
6102
6252
  *
6103
6253
  * Blob downloads use Authorization header (same as sync) via the server
6104
6254
  * proxy endpoint: GET /blob/{ref}
@@ -6109,8 +6259,6 @@ function createBlobResolveMiddleware(db) {
6109
6259
  name: 'blobResolve',
6110
6260
  level: 2, // Run above cache (0) and other middlewares (1) to resolve BlobRefs from cached data
6111
6261
  create(downlevelDatabase) {
6112
- // Create a single queue instance for this database
6113
- const blobSavingQueue = new BlobSavingQueue(db);
6114
6262
  return Object.assign(Object.assign({}, downlevelDatabase), { table(tableName) {
6115
6263
  var _a;
6116
6264
  if (!db.cloud) {
@@ -6131,7 +6279,7 @@ function createBlobResolveMiddleware(db) {
6131
6279
  }
6132
6280
  return downlevelTable.get(req).then((result) => {
6133
6281
  if (result && hasUnresolvedBlobRefs(result)) {
6134
- return resolveAndSave(downlevelTable, req.trans, req.key, result, blobSavingQueue, db);
6282
+ return resolveAndSave(downlevelTable, req.trans, req.key, result, db);
6135
6283
  }
6136
6284
  return result;
6137
6285
  });
@@ -6148,7 +6296,7 @@ function createBlobResolveMiddleware(db) {
6148
6296
  return results;
6149
6297
  return Dexie.Promise.all(results.map((result, index) => {
6150
6298
  if (result && hasUnresolvedBlobRefs(result)) {
6151
- return resolveAndSave(downlevelTable, req.trans, req.keys[index], result, blobSavingQueue, db);
6299
+ return resolveAndSave(downlevelTable, req.trans, req.keys[index], result, db);
6152
6300
  }
6153
6301
  return result;
6154
6302
  }));
@@ -6168,7 +6316,7 @@ function createBlobResolveMiddleware(db) {
6168
6316
  return result;
6169
6317
  return Dexie.Promise.all(result.result.map((item) => {
6170
6318
  if (item && hasUnresolvedBlobRefs(item)) {
6171
- return resolveAndSave(downlevelTable, req.trans, undefined, item, blobSavingQueue, db);
6319
+ return resolveAndSave(downlevelTable, req.trans, undefined, item, db);
6172
6320
  }
6173
6321
  return item;
6174
6322
  })).then((resolved) => (Object.assign(Object.assign({}, result), { result: resolved })));
@@ -6186,7 +6334,7 @@ function createBlobResolveMiddleware(db) {
6186
6334
  return cursor; // No values requested, so no resolution needed
6187
6335
  if (!dbUrl)
6188
6336
  return cursor; // No database URL configured, can't resolve blobs
6189
- return createBlobResolvingCursor(cursor, downlevelTable, blobSavingQueue, db);
6337
+ return createBlobResolvingCursor(cursor, downlevelTable, db);
6190
6338
  });
6191
6339
  } });
6192
6340
  } });
@@ -6203,7 +6351,7 @@ function createBlobResolveMiddleware(db) {
6203
6351
  * Returns the cursor synchronously. Resolution happens in start() before
6204
6352
  * each onNext callback, ensuring cursor.value is always available.
6205
6353
  */
6206
- function createBlobResolvingCursor(cursor, table, blobSavingQueue, db) {
6354
+ function createBlobResolvingCursor(cursor, table, db) {
6207
6355
  // Create wrapped cursor using Object.create() - inherits everything.
6208
6356
  // Important: .key and .primaryKey must be explicitly overridden with
6209
6357
  // closure-based getters to prevent native IDBCursorWithValue getters from
@@ -6211,11 +6359,15 @@ function createBlobResolvingCursor(cursor, table, blobSavingQueue, db) {
6211
6359
  // throws "Illegal invocation" in Chrome 146+.
6212
6360
  const wrappedCursor = Object.create(cursor, {
6213
6361
  key: {
6214
- get() { return cursor.key; },
6362
+ get() {
6363
+ return cursor.key;
6364
+ },
6215
6365
  configurable: true,
6216
6366
  },
6217
6367
  primaryKey: {
6218
- get() { return cursor.primaryKey; },
6368
+ get() {
6369
+ return cursor.primaryKey;
6370
+ },
6219
6371
  configurable: true,
6220
6372
  },
6221
6373
  value: {
@@ -6233,7 +6385,7 @@ function createBlobResolvingCursor(cursor, table, blobSavingQueue, db) {
6233
6385
  onNext();
6234
6386
  return;
6235
6387
  }
6236
- resolveAndSave(table, cursor.trans, cursor.primaryKey, rawValue, blobSavingQueue, db, true).then((resolved) => {
6388
+ resolveAndSave(table, cursor.trans, cursor.primaryKey, rawValue, db, true).then((resolved) => {
6237
6389
  wrappedCursor.value = resolved;
6238
6390
  onNext();
6239
6391
  }, (err) => {
@@ -6261,7 +6413,7 @@ function createBlobResolvingCursor(cursor, table, blobSavingQueue, db) {
6261
6413
  * Returns Dexie.Promise to preserve PSD context.
6262
6414
  */
6263
6415
  function resolveAndSave(table, trans, pKey, // optional. If missing, tries to extract from object using primary key path
6264
- obj, blobSavingQueue, db, isCursorValue = false // Flag to indicate if we're resolving a cursor value (which may not have a primary key)
6416
+ obj, db, isCursorValue = false // Flag to indicate if we're resolving a cursor value (which may not have a primary key)
6265
6417
  ) {
6266
6418
  var _a;
6267
6419
  try {
@@ -6298,21 +6450,19 @@ obj, blobSavingQueue, db, isCursorValue = false // Flag to indicate if we're res
6298
6450
  ? Dexie.getByKeyPath(obj, primaryKey.keyPath)
6299
6451
  : undefined;
6300
6452
  if (key !== undefined) {
6301
- // Queue each resolved blob individually for atomic update
6302
- // This uses setTimeout(fn, 0) to completely isolate from
6303
- // Dexie's transaction context (avoids inheriting PSD)
6304
- if (isReadonly) {
6305
- blobSavingQueue.saveBlobs(table.name, key, resolvedBlobs);
6306
- }
6307
- else {
6308
- // For rw transactions, we can save directly without queueing
6309
- // since we're still in the same transaction context
6310
- table
6311
- .mutate({ type: 'put', keys: [key], values: [resolved], trans })
6312
- .catch((err) => {
6313
- console.error(`Failed to save resolved blob on ${table.name}:${key}:`, err);
6314
- });
6315
- }
6453
+ // Hand off persistence to the tracker. The tracker owns an
6454
+ // internal save-queue that runs in a fresh JS task (setTimeout 0)
6455
+ // completely outside any PSD context, so opening a Dexie rw
6456
+ // transaction there is always safe regardless of the calling
6457
+ // context. The tracker also keeps the in-flight download cache
6458
+ // alive until the save completes, so concurrent readers piggyback
6459
+ // on the already-downloaded data instead of refetching.
6460
+ db.blobDownloadTracker.enqueueSave(table.name, key, resolvedBlobs);
6461
+ }
6462
+ else if (resolvedBlobs.length > 0) {
6463
+ // No primary key — we can't persist. Release the in-flight cache
6464
+ // entries explicitly so they don't leak.
6465
+ db.blobDownloadTracker.releaseRefs(resolvedBlobs.map((b) => b.ref));
6316
6466
  }
6317
6467
  return resolved;
6318
6468
  })
@@ -8511,7 +8661,7 @@ function dexieCloud(dexie) {
8511
8661
  const downloading$ = createDownloadingState();
8512
8662
  dexie.cloud = {
8513
8663
  // @ts-ignore
8514
- version: "4.4.11",
8664
+ version: "4.4.12",
8515
8665
  options: Object.assign({}, DEFAULT_OPTIONS),
8516
8666
  schema: null,
8517
8667
  get currentUserId() {
@@ -8956,7 +9106,7 @@ function dexieCloud(dexie) {
8956
9106
  }
8957
9107
  }
8958
9108
  // @ts-ignore
8959
- dexieCloud.version = "4.4.11";
9109
+ dexieCloud.version = "4.4.12";
8960
9110
  Dexie.Cloud = dexieCloud;
8961
9111
 
8962
9112
  export { dexieCloud as default, defineYDocTrigger, dexieCloud, getTiedObjectId, getTiedRealmId, resolveText };