dexie-cloud-addon 4.4.10 → 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.10, Sat Apr 04 2026
11
+ * Version 4.4.12, Mon May 25 2026
12
12
  *
13
13
  * https://dexie.org
14
14
  *
@@ -2923,192 +2923,145 @@
2923
2923
  }
2924
2924
 
2925
2925
  /**
2926
- * Eager Blob Downloader
2926
+ * BlobSavingQueue - Queues resolved blobs for saving back to IndexedDB.
2927
2927
  *
2928
- * Downloads unresolved blobs in the background when blobMode='eager'.
2929
- * Called after sync completes to prefetch blobs for offline access.
2928
+ * This is an internal collaborator of BlobDownloadTracker and is not
2929
+ * intended to be used directly by middleware or other code. See
2930
+ * BlobDownloadTracker.enqueueSave().
2930
2931
  *
2931
- * Progress is tracked automatically via liveQuery in blobProgress.ts
2932
- * no manual progress reporting needed here.
2933
- */
2934
- /**
2935
- * Download all unresolved blobs in the background.
2936
- *
2937
- * This is called when blobMode='eager' (default) after sync completes.
2938
- * BlobRef URLs are signed (SAS tokens) so no auth header needed.
2932
+ * Uses setTimeout(fn, 0) instead of queueMicrotask to completely isolate
2933
+ * from Dexie's Promise.PSD context. This prevents the save operation
2934
+ * from inheriting any ongoing transaction.
2939
2935
  *
2940
- * Each blob is saved atomically using Table.update() to avoid race conditions.
2936
+ * Each blob is saved atomically using downCore transaction with the specific
2937
+ * keyPath to avoid race conditions with other property changes.
2941
2938
  */
2942
- function downloadUnresolvedBlobs(db, downloading$, signal) {
2943
- return __awaiter(this, void 0, void 0, function* () {
2944
- var _a;
2945
- const debugLog = (msg) => console.debug(`[dexie-cloud] ${msg}`);
2946
- debugLog('Eager download: Starting...');
2947
- // Scan for unresolved blobs
2948
- const syncedTables = getSyncableTables(db);
2949
- let hasWork = false;
2950
- for (const table of syncedTables) {
2951
- try {
2952
- const hasIndex = !!table.schema.idxByName['_hasBlobRefs'];
2953
- if (!hasIndex)
2954
- continue;
2955
- const count = yield table.where('_hasBlobRefs').equals(1).count();
2956
- if (count > 0) {
2957
- hasWork = true;
2958
- break;
2959
- }
2960
- }
2961
- catch (_b) {
2962
- // skip
2963
- }
2939
+ class BlobSavingQueue {
2940
+ constructor(db, onPersisted) {
2941
+ this.queue = [];
2942
+ this.isProcessing = false;
2943
+ this.drainResolvers = [];
2944
+ this.db = db;
2945
+ this.onPersisted = onPersisted;
2946
+ }
2947
+ /**
2948
+ * Queue a resolved blob for saving.
2949
+ * Only the specific blob property will be updated atomically.
2950
+ */
2951
+ saveBlobs(tableName, primaryKey, resolvedBlobs) {
2952
+ this.queue.push({
2953
+ tableName,
2954
+ primaryKey,
2955
+ resolvedBlobs,
2956
+ });
2957
+ this.startConsumer();
2958
+ }
2959
+ /**
2960
+ * Returns a promise that resolves when the queue is empty AND no item
2961
+ * is currently being processed. Used by callers that need to know when
2962
+ * all previously enqueued saves have been persisted to IndexedDB before
2963
+ * making decisions based on the on-disk state (e.g., the eager blob
2964
+ * downloader looping over `_hasBlobRefs=1` rows in chunks).
2965
+ *
2966
+ * Note: New work enqueued AFTER drain() is called does NOT extend the
2967
+ * wait. Callers that race against concurrent producers should treat the
2968
+ * returned promise as "queue was empty at some point after this call".
2969
+ */
2970
+ drain() {
2971
+ if (!this.isProcessing && this.queue.length === 0) {
2972
+ return Promise.resolve();
2964
2973
  }
2965
- if (!hasWork) {
2966
- debugLog('Eager download: No blobs remaining, exiting');
2974
+ return new Promise((resolve) => {
2975
+ this.drainResolvers.push(resolve);
2976
+ });
2977
+ }
2978
+ /**
2979
+ * Start the consumer if not already processing.
2980
+ * Uses setTimeout(fn, 0) to completely break out of any
2981
+ * Dexie transaction context (Promise.PSD).
2982
+ */
2983
+ startConsumer() {
2984
+ if (this.isProcessing)
2967
2985
  return;
2968
- }
2969
- setDownloadingState(downloading$, true);
2970
- try {
2971
- debugLog(`Eager download: Found ${syncedTables.length} syncable tables: ${syncedTables.map((t) => t.name).join(', ')}`);
2972
- for (const table of syncedTables) {
2973
- if (signal === null || signal === void 0 ? void 0 : signal.aborted)
2974
- ;
2975
- try {
2976
- // Check if table has _hasBlobRefs index
2977
- const hasIndex = table.schema.indexes.some((idx) => idx.name === '_hasBlobRefs');
2978
- if (!hasIndex)
2979
- continue;
2980
- // Query objects with _hasBlobRefs marker
2981
- const unresolvedObjects = yield table
2982
- .where('_hasBlobRefs')
2983
- .equals(1)
2984
- .toArray();
2985
- debugLog(`Eager download: Table ${table.name} has ${unresolvedObjects.length} unresolved objects`);
2986
- const databaseUrl = (_a = db.cloud.options) === null || _a === void 0 ? void 0 : _a.databaseUrl;
2987
- if (!databaseUrl)
2988
- throw new Error('Database URL is required to download blobs');
2989
- // Download up to MAX_CONCURRENT blobs in parallel
2990
- const MAX_CONCURRENT = 6;
2991
- const primaryKey = table.schema.primKey;
2992
- // Filter to actionable objects first
2993
- const pending = unresolvedObjects.filter((obj) => {
2994
- if (!hasUnresolvedBlobRefs(obj))
2995
- return false;
2996
- const key = primaryKey.keyPath
2997
- ? Dexie.getByKeyPath(obj, primaryKey.keyPath)
2998
- : undefined;
2999
- return key !== undefined;
3000
- });
3001
- // Process in parallel with concurrency limit
3002
- let i = 0;
3003
- const runNext = () => __awaiter(this, void 0, void 0, function* () {
3004
- while (i < pending.length) {
3005
- if (signal === null || signal === void 0 ? void 0 : signal.aborted)
3006
- ;
3007
- const obj = pending[i++];
3008
- const key = Dexie.getByKeyPath(obj, primaryKey.keyPath);
3009
- try {
3010
- // Refresh token per object — cheap (returns cached) but ensures
3011
- // we pick up renewed tokens during long download sessions.
3012
- const resolvedBlobs = [];
3013
- yield resolveAllBlobRefs(obj, databaseUrl, resolvedBlobs, '', new WeakMap(), db.blobDownloadTracker);
3014
- const updateSpec = {
3015
- _hasBlobRefs: undefined,
3016
- };
3017
- for (const blob of resolvedBlobs) {
3018
- updateSpec[blob.keyPath] = blob.data;
3019
- }
3020
- debugLog(`Eager download: Updating ${table.name}:${key} with ${resolvedBlobs.length} blobs`);
3021
- yield table.update(key, updateSpec);
3022
- // liveQuery in blobProgress.ts auto-detects this change
3023
- }
3024
- catch (err) {
3025
- console.error(`Failed to download blobs for ${table.name}:${key}:`, err);
3026
- }
3027
- }
3028
- });
3029
- // Launch up to MAX_CONCURRENT workers
3030
- const workers = [];
3031
- for (let w = 0; w < Math.min(MAX_CONCURRENT, pending.length); w++) {
3032
- workers.push(runNext());
3033
- }
3034
- yield Promise.all(workers);
3035
- }
3036
- catch (err) {
3037
- // Table might not have _hasBlobRefs index or other issues - skip silently
3038
- }
3039
- }
3040
- }
3041
- finally {
3042
- setDownloadingState(downloading$, false);
3043
- }
3044
- });
3045
- }
3046
-
3047
- //const hasSW = 'serviceWorker' in navigator;
3048
- let hasComplainedAboutSyncEvent = false;
3049
- function registerSyncEvent(db, purpose) {
3050
- return __awaiter(this, void 0, void 0, function* () {
3051
- try {
3052
- // Send sync event to SW:
3053
- const sw = yield navigator.serviceWorker.ready;
3054
- if (purpose === 'push' && sw.sync) {
3055
- yield sw.sync.register(`dexie-cloud:${db.name}`);
3056
- }
3057
- if (sw.active) {
3058
- // Use postMessage for pull syncs and for browsers not supporting sync event (Firefox, Safari).
3059
- // Also chromium based browsers with sw.sync as a fallback for sleepy sync events not taking action for a while.
3060
- sw.active.postMessage({
3061
- type: 'dexie-cloud-sync',
3062
- dbName: db.name,
3063
- purpose,
3064
- });
3065
- }
3066
- else {
3067
- throw new Error(`Failed to trigger sync - there's no active service worker`);
2986
+ this.isProcessing = true;
2987
+ // Use setTimeout to completely isolate from Dexie's PSD context
2988
+ // queueMicrotask would risk inheriting the current transaction
2989
+ setTimeout(() => {
2990
+ this.processQueue();
2991
+ }, 0);
2992
+ }
2993
+ /**
2994
+ * Process all queued blobs.
2995
+ * Runs in a completely isolated context (no inherited transaction).
2996
+ * Uses atomic updates to avoid race conditions.
2997
+ */
2998
+ processQueue() {
2999
+ const item = this.queue.shift();
3000
+ if (!item) {
3001
+ this.isProcessing = false;
3002
+ // Fire any pending drain() waiters. New saveBlobs() calls that
3003
+ // arrive after this point will start a fresh processing cycle
3004
+ // and have their own drain() semantics.
3005
+ const resolvers = this.drainResolvers;
3006
+ if (resolvers.length > 0) {
3007
+ this.drainResolvers = [];
3008
+ for (const resolve of resolvers)
3009
+ resolve();
3068
3010
  }
3069
3011
  return;
3070
3012
  }
3071
- catch (e) {
3072
- if (!hasComplainedAboutSyncEvent) {
3073
- console.debug(`Dexie Cloud: Could not register sync event`, e);
3074
- hasComplainedAboutSyncEvent = true;
3013
+ // Atomic update of just the blob property
3014
+ this.db
3015
+ .transaction('rw', item.tableName, (tx) => {
3016
+ const trans = tx.idbtrans;
3017
+ trans.disableChangeTracking = true; // Don't regard this as a change for sync purposes
3018
+ trans.disableAccessControl = true; // Bypass any access control checks since this is an internal operation
3019
+ trans.disableBlobResolve = true; // Custom flag to skip blob resolve middleware during this transaction
3020
+ const updateSpec = {};
3021
+ for (const blob of item.resolvedBlobs) {
3022
+ updateSpec[blob.keyPath] = blob.data;
3075
3023
  }
3076
- }
3077
- });
3078
- }
3079
- function registerPeriodicSyncEvent(db) {
3080
- return __awaiter(this, void 0, void 0, function* () {
3081
- var _a;
3082
- try {
3083
- // Register periodicSync event to SW:
3084
- // @ts-ignore
3085
- const { periodicSync } = yield navigator.serviceWorker.ready;
3086
- if (periodicSync) {
3087
- try {
3088
- yield periodicSync.register(`dexie-cloud:${db.name}`, (_a = db.cloud.options) === null || _a === void 0 ? void 0 : _a.periodicSync);
3089
- console.debug(`Dexie Cloud: Successfully registered periodicsync event for ${db.name}`);
3090
- }
3091
- catch (e) {
3092
- console.debug(`Dexie Cloud: Failed to register periodic sync. Your PWA must be installed to allow background sync.`, e);
3024
+ tx.table(item.tableName).update(item.primaryKey, (obj) => {
3025
+ // Check that object still has the same unresolved blob refs before applying update (i.e. it hasn't been modified since we read it)
3026
+ for (const blob of item.resolvedBlobs) {
3027
+ // 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.
3028
+ const currentValue = Dexie.getByKeyPath(obj, blob.keyPath);
3029
+ if (currentValue === undefined) {
3030
+ // Blob property was removed - skip updating this blob
3031
+ continue;
3032
+ }
3033
+ if (!isBlobRef(currentValue)) {
3034
+ // Blob property was modified to a non-blob-ref value - skip updating this blob
3035
+ continue;
3036
+ }
3037
+ if (currentValue.ref !== blob.ref) {
3038
+ // Blob property was modified - skip updating this blob
3039
+ return; // Stop. Another items has been queued to fully fix the object.
3040
+ }
3041
+ Dexie.setByKeyPath(obj, blob.keyPath, blob.data);
3093
3042
  }
3094
- }
3095
- else {
3096
- console.debug(`Dexie Cloud: periodicSync not supported.`);
3097
- }
3098
- }
3099
- catch (e) {
3100
- console.debug(`Dexie Cloud: Could not register periodicSync for ${db.name}`, e);
3101
- }
3102
- });
3103
- }
3104
-
3105
- function triggerSync(db, purpose) {
3106
- if (db.cloud.usingServiceWorker) {
3107
- console.debug('registering sync event');
3108
- registerSyncEvent(db, purpose);
3109
- }
3110
- else {
3111
- db.localSyncEvent.next({ purpose });
3043
+ delete obj._hasBlobRefs; // Clear the _hasBlobRefs marker if all refs was resolved.
3044
+ });
3045
+ // Note: we intentionally do NOT clear trans.mutatedParts here.
3046
+ // Letting the normal mutation signal through means the
3047
+ // blobProgress liveQuery (and any user-defined liveQuery that
3048
+ // depends on the resolved fields) wakes up and reflects progress
3049
+ // as blobs land in IndexedDB.
3050
+ })
3051
+ .catch((error) => {
3052
+ console.error(`Error saving resolved blobs on ${item.tableName}:${item.primaryKey}:`, error);
3053
+ })
3054
+ .finally(() => {
3055
+ // At this point, the transaction has completed (either successfully or with error),
3056
+ // and the blobs have been saved (or failed to save).
3057
+ // Notify the owner (BlobDownloadTracker) so it can release the
3058
+ // in-flight download cache entries for these refs. The cache was
3059
+ // kept alive until now to maximize reuse while the blob was still
3060
+ // in-flight (downloading or queued for save).
3061
+ this.onPersisted(item.resolvedBlobs.map((b) => b.ref));
3062
+ // Process next item in the queue
3063
+ return this.processQueue();
3064
+ });
3112
3065
  }
3113
3066
  }
3114
3067
 
@@ -3564,62 +3517,454 @@
3564
3517
  });
3565
3518
  throw error;
3566
3519
  }
3567
- let message = `We're having a problem authenticating right now.`;
3568
- console.error(`Error authenticating`, error);
3569
- if (error instanceof TypeError) {
3570
- const isOffline = typeof navigator !== 'undefined' && !navigator.onLine;
3571
- if (isOffline) {
3572
- message = `You seem to be offline. Please connect to the internet and try again.`;
3573
- }
3574
- else if (typeof location !== 'undefined' &&
3575
- (Dexie.debug ||
3576
- location.hostname === 'localhost' ||
3577
- location.hostname === '127.0.0.1')) {
3578
- // The audience is most likely the developer. Suggest to whitelist the localhost origin:
3579
- const whitelistCommand = `npx dexie-cloud whitelist ${location.origin}`;
3580
- message = `Could not connect to server. Please verify that your origin '${location.origin}' is whitelisted using \`npx dexie-cloud whitelist\``;
3581
- yield alertUser(userInteraction, 'Authentication Failed', {
3582
- type: 'error',
3583
- messageCode: 'GENERIC_ERROR',
3584
- message,
3585
- messageParams: {},
3586
- copyText: whitelistCommand,
3587
- }).catch(() => { });
3520
+ let message = `We're having a problem authenticating right now.`;
3521
+ console.error(`Error authenticating`, error);
3522
+ if (error instanceof TypeError) {
3523
+ const isOffline = typeof navigator !== 'undefined' && !navigator.onLine;
3524
+ if (isOffline) {
3525
+ message = `You seem to be offline. Please connect to the internet and try again.`;
3526
+ }
3527
+ else if (typeof location !== 'undefined' &&
3528
+ (Dexie.debug ||
3529
+ location.hostname === 'localhost' ||
3530
+ location.hostname === '127.0.0.1')) {
3531
+ // The audience is most likely the developer. Suggest to whitelist the localhost origin:
3532
+ const whitelistCommand = `npx dexie-cloud whitelist ${location.origin}`;
3533
+ message = `Could not connect to server. Please verify that your origin '${location.origin}' is whitelisted using \`npx dexie-cloud whitelist\``;
3534
+ yield alertUser(userInteraction, 'Authentication Failed', {
3535
+ type: 'error',
3536
+ messageCode: 'GENERIC_ERROR',
3537
+ message,
3538
+ messageParams: {},
3539
+ copyText: whitelistCommand,
3540
+ }).catch(() => { });
3541
+ }
3542
+ else {
3543
+ message = `Could not connect to server. Please verify the connection.`;
3544
+ yield alertUser(userInteraction, 'Authentication Failed', {
3545
+ type: 'error',
3546
+ messageCode: 'GENERIC_ERROR',
3547
+ message,
3548
+ messageParams: {},
3549
+ }).catch(() => { });
3550
+ }
3551
+ }
3552
+ throw error;
3553
+ }
3554
+ });
3555
+ }
3556
+ function spkiToPEM(keydata) {
3557
+ const keydataB64 = b64encode(keydata);
3558
+ const keydataB64Pem = formatAsPem(keydataB64);
3559
+ return keydataB64Pem;
3560
+ }
3561
+ function formatAsPem(str) {
3562
+ let finalString = '-----BEGIN PUBLIC KEY-----\n';
3563
+ while (str.length > 0) {
3564
+ finalString += str.substring(0, 64) + '\n';
3565
+ str = str.substring(64);
3566
+ }
3567
+ finalString = finalString + '-----END PUBLIC KEY-----';
3568
+ return finalString;
3569
+ }
3570
+
3571
+ const wm$4 = new WeakMap();
3572
+ function loadCachedAccessToken(db) {
3573
+ var _a, _b, _c, _d;
3574
+ let cached = wm$4.get(db);
3575
+ if (cached && cached.expiration > Date.now() + 5 * MINUTES) {
3576
+ return Promise.resolve(cached.accessToken);
3577
+ }
3578
+ const currentUser = db.cloud.currentUser.value;
3579
+ if (currentUser &&
3580
+ currentUser.accessToken &&
3581
+ ((_b = (_a = currentUser.accessTokenExpiration) === null || _a === void 0 ? void 0 : _a.getTime()) !== null && _b !== void 0 ? _b : Infinity) >
3582
+ Date.now() + 5 * MINUTES) {
3583
+ wm$4.set(db, {
3584
+ accessToken: currentUser.accessToken,
3585
+ expiration: (_d = (_c = currentUser.accessTokenExpiration) === null || _c === void 0 ? void 0 : _c.getTime()) !== null && _d !== void 0 ? _d : Infinity,
3586
+ });
3587
+ return Promise.resolve(currentUser.accessToken);
3588
+ }
3589
+ // If the current user is not logged in (no isLoggedIn flag), there's no
3590
+ // token to load from the database — skip the Dexie.ignoreTransaction() call.
3591
+ // This avoids a crash in service worker context where Dexie's Promise zone
3592
+ // (PSD.transless.env) may be undefined when called from within an active
3593
+ // rw transaction (e.g. during applyServerChanges).
3594
+ if (!(currentUser === null || currentUser === void 0 ? void 0 : currentUser.isLoggedIn)) {
3595
+ return Promise.resolve(null);
3596
+ }
3597
+ return Dexie.ignoreTransaction(() => loadAccessToken(db).then((user) => {
3598
+ var _a, _b;
3599
+ if (user === null || user === void 0 ? void 0 : user.accessToken) {
3600
+ wm$4.set(db, {
3601
+ accessToken: user.accessToken,
3602
+ expiration: (_b = (_a = user.accessTokenExpiration) === null || _a === void 0 ? void 0 : _a.getTime()) !== null && _b !== void 0 ? _b : Infinity,
3603
+ });
3604
+ }
3605
+ return (user === null || user === void 0 ? void 0 : user.accessToken) || null;
3606
+ }));
3607
+ }
3608
+
3609
+ /**
3610
+ * Owns the full lifecycle of downloaded blobs:
3611
+ * 1. Deduplicates concurrent downloads for the same ref.
3612
+ * 2. Bounds the number of concurrent network fetches (MAX_CONCURRENT)
3613
+ * so that ad-hoc reads can't starve the HTTP connection pool. Calls
3614
+ * beyond the cap queue in FIFO order as slots free. The slot is held
3615
+ * only for the duration of the fetch — NOT until persistence — to
3616
+ * avoid deadlocks when a single object contains more blob refs than
3617
+ * MAX_CONCURRENT (a sequential resolver would otherwise hold every
3618
+ * slot itself while waiting for the next).
3619
+ * 3. Keeps the in-flight promise alive after the network fetch completes,
3620
+ * until the blob has been persisted back to IndexedDB. This way,
3621
+ * readers that ask for the same ref while it is queued for saving
3622
+ * can piggyback on the existing promise instead of refetching.
3623
+ * In-flight membership and slot ownership are independent: a piggyback
3624
+ * reader consumes neither a slot nor extra memory beyond the existing
3625
+ * cached Uint8Array.
3626
+ * 4. Persists resolved blobs via an internal BlobSavingQueue, and
3627
+ * releases the in-flight entry when persistence completes.
3628
+ *
3629
+ * Both the blob-resolve middleware and the eager blob downloader use this
3630
+ * tracker. Instantiate once per DexieCloudDB.
3631
+ */
3632
+ /**
3633
+ * Maximum number of concurrent blob fetches.
3634
+ *
3635
+ * Historically 6 to match the HTTP/1.1 same-origin connection cap that
3636
+ * browsers enforce. With HTTP/2 (the typical transport for Dexie Cloud
3637
+ * today) many streams multiplex over a single TCP connection, so the
3638
+ * old cap is overly conservative. 10 is a modest bump that still keeps
3639
+ * memory pressure (in-flight Uint8Arrays) and server load bounded.
3640
+ * Can be made configurable via DexieCloudOptions if a real need arises.
3641
+ */
3642
+ const MAX_CONCURRENT = 10;
3643
+ class BlobDownloadTracker {
3644
+ constructor(db) {
3645
+ this.inFlight = new Map();
3646
+ this.activeFetches = 0;
3647
+ this.waiting = [];
3648
+ this.db = db;
3649
+ this.savingQueue = new BlobSavingQueue(db, (refs) => {
3650
+ // Called by the queue when a save transaction has completed
3651
+ // (regardless of success). Drop the in-flight cache entries now —
3652
+ // any future reader will go through IndexedDB instead.
3653
+ for (const ref of refs) {
3654
+ this.inFlight.delete(ref);
3655
+ }
3656
+ });
3657
+ }
3658
+ /**
3659
+ * Download a blob, deduplicating concurrent requests for the same ref
3660
+ * and respecting the global fetch concurrency cap.
3661
+ *
3662
+ * Lifecycle:
3663
+ * - Slot is acquired before the fetch and released as soon as the
3664
+ * fetch settles (success or failure).
3665
+ * - The in-flight entry survives a successful fetch and lives on
3666
+ * until persistence completes (via enqueueSave) or releaseRefs
3667
+ * is called. On fetch failure, the entry is removed immediately
3668
+ * so a future call can retry.
3669
+ *
3670
+ * @param blobRef - The BlobRef to download
3671
+ * @param dbUrl - Base URL for the database (e.g., 'https://mydb.dexie.cloud')
3672
+ */
3673
+ download(blobRef, dbUrl) {
3674
+ let promise = this.inFlight.get(blobRef.ref);
3675
+ if (!promise) {
3676
+ promise = this.acquireSlot()
3677
+ .then(() => this.downloadBlob(blobRef, dbUrl).finally(() => this.releaseSlot()))
3678
+ .catch((err) => {
3679
+ // On error, remove immediately so a future call can retry.
3680
+ // (Slot already released by the .finally above.)
3681
+ this.inFlight.delete(blobRef.ref);
3682
+ throw err;
3683
+ });
3684
+ this.inFlight.set(blobRef.ref, promise);
3685
+ }
3686
+ return promise;
3687
+ }
3688
+ /**
3689
+ * Queue resolved blobs for persisting back to IndexedDB.
3690
+ * When the save transaction completes, the corresponding in-flight
3691
+ * entries are released.
3692
+ */
3693
+ enqueueSave(tableName, primaryKey, resolvedBlobs) {
3694
+ this.savingQueue.saveBlobs(tableName, primaryKey, resolvedBlobs);
3695
+ }
3696
+ /**
3697
+ * Wait until all previously enqueued saves have been persisted to
3698
+ * IndexedDB. Used by callers that need to make decisions based on
3699
+ * on-disk state — e.g., the eager downloader looping over rows with
3700
+ * `_hasBlobRefs=1` in chunks, where each iteration must see the
3701
+ * previous chunk's writes before re-querying.
3702
+ *
3703
+ * New saves enqueued AFTER drainPendingSaves() is called do NOT extend
3704
+ * the wait.
3705
+ */
3706
+ drainPendingSaves() {
3707
+ return this.savingQueue.drain();
3708
+ }
3709
+ /**
3710
+ * Release in-flight entries without going through the internal saving
3711
+ * queue. Used when the caller persists the blobs itself, or when no
3712
+ * primary key was available and the data won't be persisted at all.
3713
+ */
3714
+ releaseRefs(refs) {
3715
+ for (const ref of refs) {
3716
+ this.inFlight.delete(ref);
3717
+ }
3718
+ }
3719
+ acquireSlot() {
3720
+ if (this.activeFetches < MAX_CONCURRENT) {
3721
+ this.activeFetches++;
3722
+ return Promise.resolve();
3723
+ }
3724
+ return new Promise((resolve) => {
3725
+ this.waiting.push(() => {
3726
+ this.activeFetches++;
3727
+ resolve();
3728
+ });
3729
+ });
3730
+ }
3731
+ releaseSlot() {
3732
+ this.activeFetches--;
3733
+ const next = this.waiting.shift();
3734
+ if (next)
3735
+ next();
3736
+ }
3737
+ /**
3738
+ * Download blob data from server via proxy endpoint.
3739
+ * Uses auth header for authentication (same as sync).
3740
+ * When accessToken is null, the request is made without Authorization header —
3741
+ * this allows downloading blobs from public realms (rlm-public) for
3742
+ * unauthenticated users.
3743
+ *
3744
+ * @param blobRef - The BlobRef to download
3745
+ * @param dbUrl - Base URL for the database (e.g., 'https://mydb.dexie.cloud')
3746
+ */
3747
+ downloadBlob(blobRef, dbUrl) {
3748
+ return __awaiter(this, void 0, void 0, function* () {
3749
+ const accessToken = yield loadCachedAccessToken(this.db);
3750
+ const downloadUrl = `${dbUrl}/blob/${blobRef.ref}`;
3751
+ const headers = {};
3752
+ if (accessToken) {
3753
+ // accessToken may be null for anonymous/unauthenticated users.
3754
+ // Public realm blobs (rlm-public) are accessible without auth.
3755
+ // downloadBlob will omit the Authorization header when token is null.
3756
+ headers['Authorization'] = `Bearer ${accessToken}`;
3757
+ }
3758
+ // cache: 'no-store' prevents the browser from storing this response in its
3759
+ // HTTP cache. The server sets a long Expires/Cache-Control header on blob
3760
+ // responses (blobs are immutable and content-addressed), which would
3761
+ // otherwise cause the browser to keep a copy in its disk cache in addition
3762
+ // to the copy we persist to IndexedDB — doubling storage for every blob.
3763
+ // Since we always persist to IndexedDB and subsequent reads go through
3764
+ // IndexedDB (never re-fetch), the browser cache copy is pure overhead.
3765
+ const response = yield fetch(downloadUrl, { headers, cache: 'no-store' });
3766
+ if (!response.ok) {
3767
+ throw new Error(`Failed to download blob ${blobRef.ref}: ${response.status} ${response.statusText}`);
3768
+ }
3769
+ const arrayBuffer = yield response.arrayBuffer();
3770
+ return new Uint8Array(arrayBuffer);
3771
+ });
3772
+ }
3773
+ }
3774
+
3775
+ /**
3776
+ * Eager Blob Downloader
3777
+ *
3778
+ * Downloads unresolved blobs in the background when blobMode='eager'.
3779
+ * Called after sync completes to prefetch blobs for offline access.
3780
+ *
3781
+ * Strategy:
3782
+ * 1. Snapshot the primary keys of all rows currently flagged
3783
+ * `_hasBlobRefs=1` for each syncable table.
3784
+ * 2. Walk that key list in chunks via `bulkGet`. Each `bulkGet`
3785
+ * triggers the blob-resolve middleware, which does all the actual
3786
+ * work — downloading blobs (throttled and deduplicated by the
3787
+ * shared BlobDownloadTracker) and enqueueing them for persistence
3788
+ * via the internal save queue.
3789
+ *
3790
+ * This keeps a single, symmetric code path with normal application
3791
+ * reads, which is important when other middlewares are present
3792
+ * (e.g., a hypothetical encryption middleware): writes from the save
3793
+ * queue and reads from this loop both pass through the full middleware
3794
+ * stack, so on-disk representation stays consistent.
3795
+ *
3796
+ * Why a snapshot of primary keys (rather than re-querying the index)?
3797
+ * - Rows that get resolved by parallel application reads simply
3798
+ * disappear from the table contents we're about to re-fetch; the
3799
+ * middleware skips them since `_hasBlobRefs` is already cleared.
3800
+ * - Stuck rows (e.g., blob 404s) are naturally bypassed: we just
3801
+ * advance to the next chunk in the snapshot. No `seenKeys`
3802
+ * bookkeeping required.
3803
+ * - The snapshot is `string[]`-shaped for typical Dexie Cloud rows
3804
+ * (~36 bytes/UUID), so ~28K keys per MB. Acceptable for any
3805
+ * realistic dataset.
3806
+ *
3807
+ * Progress is tracked automatically via liveQuery in blobProgress.ts —
3808
+ * no manual progress reporting needed here.
3809
+ *
3810
+ * --- Throughput note ---
3811
+ * The chunk loop is sequential: bulkGet → wait for all downloads to
3812
+ * settle → next bulkGet. The save queue drains in the background and
3813
+ * does not block iteration (saves no longer need to be persisted before
3814
+ * the next iteration, since we don't re-query the index). For typical
3815
+ * blob sizes (10 KB – 10 MB) the network dominates total time. If
3816
+ * real-world profiling later shows the per-chunk fixed cost matters,
3817
+ * the next bulkGet could be kicked off in parallel with the current
3818
+ * one's middleware work — but we keep it simple until measurements
3819
+ * justify otherwise.
3820
+ */
3821
+ // One chunk = one full saturation of the tracker's concurrency semaphore.
3822
+ // Larger chunks would only buffer more downloaded Uint8Arrays in memory
3823
+ // while waiting for the save queue to persist them, without any throughput
3824
+ // benefit (the semaphore is the gate, not the bulkGet).
3825
+ const CHUNK_SIZE = MAX_CONCURRENT - 1; // Leave one slot for parallel app reads that might also trigger downloads
3826
+ /**
3827
+ * Download all unresolved blobs in the background.
3828
+ *
3829
+ * This is called when blobMode='eager' (default) after sync completes.
3830
+ */
3831
+ function downloadUnresolvedBlobs(db, downloading$, signal) {
3832
+ return __awaiter(this, void 0, void 0, function* () {
3833
+ const debugLog = (msg) => console.debug(`[dexie-cloud] ${msg}`);
3834
+ debugLog('Eager download: Starting...');
3835
+ const syncedTables = getSyncableTables(db).filter((t) => t.schema.indexes.some((idx) => idx.name === '_hasBlobRefs'));
3836
+ let started = false;
3837
+ let totalProcessed = 0;
3838
+ try {
3839
+ for (const table of syncedTables) {
3840
+ if (signal === null || signal === void 0 ? void 0 : signal.aborted)
3841
+ ;
3842
+ let keys;
3843
+ try {
3844
+ keys = yield table.where('_hasBlobRefs').equals(1).primaryKeys();
3845
+ }
3846
+ catch (err) {
3847
+ console.error(`Eager download: failed to list unresolved rows for ${table.name}:`, err);
3848
+ continue;
3849
+ }
3850
+ if (keys.length === 0)
3851
+ continue;
3852
+ if (!started) {
3853
+ setDownloadingState(downloading$, true);
3854
+ started = true;
3855
+ }
3856
+ debugLog(`Eager download: ${table.name} has ${keys.length} row(s)`);
3857
+ for (let i = 0; i < keys.length; i += CHUNK_SIZE) {
3858
+ if (signal === null || signal === void 0 ? void 0 : signal.aborted)
3859
+ ;
3860
+ const slice = keys.slice(i, i + CHUNK_SIZE);
3861
+ try {
3862
+ // bulkGet triggers the blob-resolve middleware for each row that
3863
+ // still has `_hasBlobRefs=1`. Rows already resolved by parallel
3864
+ // reads come back without the marker and the middleware no-ops.
3865
+ // Rows that have been deleted return `undefined` and are
3866
+ // likewise skipped.
3867
+ yield table.bulkGet(slice);
3868
+ }
3869
+ catch (err) {
3870
+ console.error(`Eager download: ${table.name} chunk failed:`, err);
3871
+ continue;
3872
+ }
3873
+ totalProcessed += slice.length;
3874
+ debugLog(`Eager download: ${table.name} ${Math.min(i + CHUNK_SIZE, keys.length)}/${keys.length}`);
3875
+ }
3876
+ }
3877
+ if (started) {
3878
+ // Make sure all middleware-enqueued saves have landed before we flip
3879
+ // `downloading$` to false — otherwise observers might see a "done"
3880
+ // signal while writes are still in flight.
3881
+ yield db.blobDownloadTracker.drainPendingSaves();
3882
+ debugLog(`Eager download: done (${totalProcessed} row(s) processed)`);
3883
+ }
3884
+ else {
3885
+ debugLog('Eager download: No blobs remaining, exiting');
3886
+ }
3887
+ }
3888
+ finally {
3889
+ if (started)
3890
+ setDownloadingState(downloading$, false);
3891
+ }
3892
+ });
3893
+ }
3894
+
3895
+ //const hasSW = 'serviceWorker' in navigator;
3896
+ let hasComplainedAboutSyncEvent = false;
3897
+ function registerSyncEvent(db, purpose) {
3898
+ return __awaiter(this, void 0, void 0, function* () {
3899
+ try {
3900
+ // Send sync event to SW:
3901
+ const sw = yield navigator.serviceWorker.ready;
3902
+ if (purpose === 'push' && sw.sync) {
3903
+ yield sw.sync.register(`dexie-cloud:${db.name}`);
3904
+ }
3905
+ if (sw.active) {
3906
+ // Use postMessage for pull syncs and for browsers not supporting sync event (Firefox, Safari).
3907
+ // Also chromium based browsers with sw.sync as a fallback for sleepy sync events not taking action for a while.
3908
+ sw.active.postMessage({
3909
+ type: 'dexie-cloud-sync',
3910
+ dbName: db.name,
3911
+ purpose,
3912
+ });
3913
+ }
3914
+ else {
3915
+ throw new Error(`Failed to trigger sync - there's no active service worker`);
3916
+ }
3917
+ return;
3918
+ }
3919
+ catch (e) {
3920
+ if (!hasComplainedAboutSyncEvent) {
3921
+ console.debug(`Dexie Cloud: Could not register sync event`, e);
3922
+ hasComplainedAboutSyncEvent = true;
3923
+ }
3924
+ }
3925
+ });
3926
+ }
3927
+ function registerPeriodicSyncEvent(db) {
3928
+ return __awaiter(this, void 0, void 0, function* () {
3929
+ var _a;
3930
+ try {
3931
+ // Register periodicSync event to SW:
3932
+ // @ts-ignore
3933
+ const { periodicSync } = yield navigator.serviceWorker.ready;
3934
+ if (periodicSync) {
3935
+ try {
3936
+ yield periodicSync.register(`dexie-cloud:${db.name}`, (_a = db.cloud.options) === null || _a === void 0 ? void 0 : _a.periodicSync);
3937
+ console.debug(`Dexie Cloud: Successfully registered periodicsync event for ${db.name}`);
3588
3938
  }
3589
- else {
3590
- message = `Could not connect to server. Please verify the connection.`;
3591
- yield alertUser(userInteraction, 'Authentication Failed', {
3592
- type: 'error',
3593
- messageCode: 'GENERIC_ERROR',
3594
- message,
3595
- messageParams: {},
3596
- }).catch(() => { });
3939
+ catch (e) {
3940
+ console.debug(`Dexie Cloud: Failed to register periodic sync. Your PWA must be installed to allow background sync.`, e);
3597
3941
  }
3598
3942
  }
3599
- throw error;
3943
+ else {
3944
+ console.debug(`Dexie Cloud: periodicSync not supported.`);
3945
+ }
3946
+ }
3947
+ catch (e) {
3948
+ console.debug(`Dexie Cloud: Could not register periodicSync for ${db.name}`, e);
3600
3949
  }
3601
3950
  });
3602
3951
  }
3603
- function spkiToPEM(keydata) {
3604
- const keydataB64 = b64encode(keydata);
3605
- const keydataB64Pem = formatAsPem(keydataB64);
3606
- return keydataB64Pem;
3607
- }
3608
- function formatAsPem(str) {
3609
- let finalString = '-----BEGIN PUBLIC KEY-----\n';
3610
- while (str.length > 0) {
3611
- finalString += str.substring(0, 64) + '\n';
3612
- str = str.substring(64);
3952
+
3953
+ function triggerSync(db, purpose) {
3954
+ if (db.cloud.usingServiceWorker) {
3955
+ console.debug('registering sync event');
3956
+ registerSyncEvent(db, purpose);
3957
+ }
3958
+ else {
3959
+ db.localSyncEvent.next({ purpose });
3613
3960
  }
3614
- finalString = finalString + '-----END PUBLIC KEY-----';
3615
- return finalString;
3616
3961
  }
3617
3962
 
3618
3963
  // Emulate true-private property db. Why? So it's not stored in DB.
3619
- const wm$4 = new WeakMap();
3964
+ const wm$3 = new WeakMap();
3620
3965
  class AuthPersistedContext {
3621
3966
  constructor(db, userLogin) {
3622
- wm$4.set(this, db);
3967
+ wm$3.set(this, db);
3623
3968
  Object.assign(this, userLogin);
3624
3969
  }
3625
3970
  static load(db, userId) {
@@ -3636,7 +3981,7 @@
3636
3981
  }
3637
3982
  save() {
3638
3983
  return __awaiter(this, void 0, void 0, function* () {
3639
- const db = wm$4.get(this);
3984
+ const db = wm$3.get(this);
3640
3985
  db.table('$logins').put(this);
3641
3986
  });
3642
3987
  }
@@ -14493,7 +14838,7 @@
14493
14838
  *
14494
14839
  * ==========================================================================
14495
14840
  *
14496
- * Version 4.4.0, Sat Apr 04 2026
14841
+ * Version 4.4.0, Mon May 25 2026
14497
14842
  *
14498
14843
  * https://dexie.org
14499
14844
  *
@@ -14688,7 +15033,7 @@
14688
15033
  };
14689
15034
  }
14690
15035
 
14691
- const wm$3 = new WeakMap();
15036
+ const wm$2 = new WeakMap();
14692
15037
  function createEvents() {
14693
15038
  return Dexie.Dexie.Events(null, 'load', 'sync', 'error');
14694
15039
  }
@@ -14707,7 +15052,7 @@
14707
15052
  }
14708
15053
  static load(doc, options) {
14709
15054
  var _a;
14710
- let p = wm$3.get(doc);
15055
+ let p = wm$2.get(doc);
14711
15056
  if (p) {
14712
15057
  ++p.refCount;
14713
15058
  if ((options === null || options === void 0 ? void 0 : options.gracePeriod) != null &&
@@ -14722,14 +15067,14 @@
14722
15067
  else {
14723
15068
  p = new DexieYProvider(doc);
14724
15069
  p.graceTimeout = (_a = options === null || options === void 0 ? void 0 : options.gracePeriod) !== null && _a !== void 0 ? _a : -1;
14725
- wm$3.set(doc, p);
15070
+ wm$2.set(doc, p);
14726
15071
  }
14727
15072
  return p;
14728
15073
  }
14729
15074
  static release(doc) {
14730
15075
  if (!doc || destroyedDocs.has(doc))
14731
15076
  return; // Document already destroyed.
14732
- const p = wm$3.get(doc);
15077
+ const p = wm$2.get(doc);
14733
15078
  if (p) {
14734
15079
  // There is a provider connected to the doc
14735
15080
  if (--p.refCount <= 0) {
@@ -14774,7 +15119,7 @@
14774
15119
  });
14775
15120
  }
14776
15121
  static for(doc) {
14777
- return wm$3.get(doc);
15122
+ return wm$2.get(doc);
14778
15123
  }
14779
15124
  static get currentUpdateRow() {
14780
15125
  return currentUpdateRow;
@@ -14862,7 +15207,7 @@
14862
15207
  destroy() {
14863
15208
  var _a, _b, _c;
14864
15209
  console.debug(`Y.Doc ${(_b = (_a = this.doc) === null || _a === void 0 ? void 0 : _a.meta) === null || _b === void 0 ? void 0 : _b.parentId} was destroyed`);
14865
- wm$3.delete(this.doc);
15210
+ wm$2.delete(this.doc);
14866
15211
  this.doc = null;
14867
15212
  this.destroyed = true;
14868
15213
  this.refCount = 0;
@@ -15519,44 +15864,6 @@
15519
15864
  });
15520
15865
  }
15521
15866
 
15522
- const wm$2 = new WeakMap();
15523
- function loadCachedAccessToken(db) {
15524
- var _a, _b, _c, _d;
15525
- let cached = wm$2.get(db);
15526
- if (cached && cached.expiration > Date.now() + 5 * MINUTES) {
15527
- return Promise.resolve(cached.accessToken);
15528
- }
15529
- const currentUser = db.cloud.currentUser.value;
15530
- if (currentUser &&
15531
- currentUser.accessToken &&
15532
- ((_b = (_a = currentUser.accessTokenExpiration) === null || _a === void 0 ? void 0 : _a.getTime()) !== null && _b !== void 0 ? _b : Infinity) >
15533
- Date.now() + 5 * MINUTES) {
15534
- wm$2.set(db, {
15535
- accessToken: currentUser.accessToken,
15536
- expiration: (_d = (_c = currentUser.accessTokenExpiration) === null || _c === void 0 ? void 0 : _c.getTime()) !== null && _d !== void 0 ? _d : Infinity,
15537
- });
15538
- return Promise.resolve(currentUser.accessToken);
15539
- }
15540
- // If the current user is not logged in (no isLoggedIn flag), there's no
15541
- // token to load from the database — skip the Dexie.ignoreTransaction() call.
15542
- // This avoids a crash in service worker context where Dexie's Promise zone
15543
- // (PSD.transless.env) may be undefined when called from within an active
15544
- // rw transaction (e.g. during applyServerChanges).
15545
- if (!(currentUser === null || currentUser === void 0 ? void 0 : currentUser.isLoggedIn)) {
15546
- return Promise.resolve(null);
15547
- }
15548
- return Dexie.ignoreTransaction(() => loadAccessToken(db).then((user) => {
15549
- var _a, _b;
15550
- if (user === null || user === void 0 ? void 0 : user.accessToken) {
15551
- wm$2.set(db, {
15552
- accessToken: user.accessToken,
15553
- expiration: (_b = (_a = user.accessTokenExpiration) === null || _a === void 0 ? void 0 : _a.getTime()) !== null && _b !== void 0 ? _b : Infinity,
15554
- });
15555
- }
15556
- return (user === null || user === void 0 ? void 0 : user.accessToken) || null;
15557
- }));
15558
- }
15559
-
15560
15867
  const CURRENT_SYNC_WORKER = 'currentSyncWorker';
15561
15868
  function sync(db, options, schema, syncOptions) {
15562
15869
  return _sync(db, options, schema, syncOptions)
@@ -16100,71 +16407,6 @@
16100
16407
  };
16101
16408
  }
16102
16409
 
16103
- /**
16104
- * Deduplicates in-flight blob downloads.
16105
- *
16106
- * Both the blob-resolve middleware and the eager blob downloader may
16107
- * try to fetch the same blob concurrently. This tracker ensures each
16108
- * unique blob ref is only downloaded once — subsequent requests for
16109
- * the same ref piggyback on the existing promise.
16110
- *
16111
- * Instantiate once per DexieCloudDB.
16112
- */
16113
- class BlobDownloadTracker {
16114
- constructor(db) {
16115
- this.inFlight = new Map();
16116
- this.db = db;
16117
- }
16118
- /**
16119
- * Download a blob, deduplicating concurrent requests for the same ref.
16120
- *
16121
- * @param blobRef - The BlobRef to download
16122
- * @param dbUrl - Base URL for the database (e.g., 'https://mydb.dexie.cloud')
16123
- */
16124
- download(blobRef, dbUrl) {
16125
- let promise = this.inFlight.get(blobRef.ref);
16126
- if (!promise) {
16127
- promise = loadCachedAccessToken(this.db)
16128
- .then((accessToken) => {
16129
- // accessToken may be null for anonymous/unauthenticated users.
16130
- // Public realm blobs (rlm-public) are accessible without auth.
16131
- // downloadBlob will omit the Authorization header when token is null.
16132
- return downloadBlob(blobRef, dbUrl, accessToken);
16133
- })
16134
- .finally(() => this.inFlight.delete(blobRef.ref));
16135
- // When the promise settles (either fulfilled or rejected), remove it from the in-flight map
16136
- this.inFlight.set(blobRef.ref, promise);
16137
- }
16138
- return promise;
16139
- }
16140
- }
16141
- /**
16142
- * Download blob data from server via proxy endpoint.
16143
- * Uses auth header for authentication (same as sync).
16144
- * When accessToken is null, the request is made without Authorization header —
16145
- * this allows downloading blobs from public realms (rlm-public) for
16146
- * unauthenticated users.
16147
- *
16148
- * @param blobRef - The BlobRef to download
16149
- * @param dbUrl - Base URL for the database (e.g., 'https://mydb.dexie.cloud')
16150
- * @param accessToken - Access token for authentication, or null for anonymous access
16151
- */
16152
- function downloadBlob(blobRef, dbUrl, accessToken) {
16153
- return __awaiter(this, void 0, void 0, function* () {
16154
- const downloadUrl = `${dbUrl}/blob/${blobRef.ref}`;
16155
- const headers = {};
16156
- if (accessToken) {
16157
- headers['Authorization'] = `Bearer ${accessToken}`;
16158
- }
16159
- const response = yield fetch(downloadUrl, { headers });
16160
- if (!response.ok) {
16161
- throw new Error(`Failed to download blob ${blobRef.ref}: ${response.status} ${response.statusText}`);
16162
- }
16163
- const arrayBuffer = yield response.arrayBuffer();
16164
- return new Uint8Array(arrayBuffer);
16165
- });
16166
- }
16167
-
16168
16410
  const wm$1 = new WeakMap();
16169
16411
  const DEXIE_CLOUD_SCHEMA = {
16170
16412
  members: '@id, [userId+realmId], [email+realmId], realmId',
@@ -16960,99 +17202,6 @@
16960
17202
  };
16961
17203
  }
16962
17204
 
16963
- /**
16964
- * BlobSavingQueue - Queues resolved blobs for saving back to IndexedDB
16965
- *
16966
- * Uses setTimeout(fn, 0) instead of queueMicrotask to completely isolate
16967
- * from Dexie's Promise.PSD context. This prevents the save operation
16968
- * from inheriting any ongoing transaction.
16969
- *
16970
- * Each blob is saved atomically using downCore transaction with the specific
16971
- * keyPath to avoid race conditions with other property changes.
16972
- */
16973
- class BlobSavingQueue {
16974
- constructor(db) {
16975
- this.queue = [];
16976
- this.isProcessing = false;
16977
- this.db = db;
16978
- }
16979
- /**
16980
- * Queue a resolved blob for saving.
16981
- * Only the specific blob property will be updated atomically.
16982
- */
16983
- saveBlobs(tableName, primaryKey, resolvedBlobs) {
16984
- this.queue.push({ tableName, primaryKey, resolvedBlobs });
16985
- this.startConsumer();
16986
- }
16987
- /**
16988
- * Start the consumer if not already processing.
16989
- * Uses setTimeout(fn, 0) to completely break out of any
16990
- * Dexie transaction context (Promise.PSD).
16991
- */
16992
- startConsumer() {
16993
- if (this.isProcessing)
16994
- return;
16995
- this.isProcessing = true;
16996
- // Use setTimeout to completely isolate from Dexie's PSD context
16997
- // queueMicrotask would risk inheriting the current transaction
16998
- setTimeout(() => {
16999
- this.processQueue();
17000
- }, 0);
17001
- }
17002
- /**
17003
- * Process all queued blobs.
17004
- * Runs in a completely isolated context (no inherited transaction).
17005
- * Uses atomic updates to avoid race conditions.
17006
- */
17007
- processQueue() {
17008
- const item = this.queue.shift();
17009
- if (!item) {
17010
- this.isProcessing = false;
17011
- return;
17012
- }
17013
- // Atomic update of just the blob property
17014
- this.db
17015
- .transaction('rw', item.tableName, (tx) => {
17016
- const trans = tx.idbtrans;
17017
- trans.disableChangeTracking = true; // Don't regard this as a change for sync purposes
17018
- trans.disableAccessControl = true; // Bypass any access control checks since this is an internal operation
17019
- trans.disableBlobResolve = true; // Custom flag to skip blob resolve middleware during this transaction
17020
- const updateSpec = {};
17021
- for (const blob of item.resolvedBlobs) {
17022
- updateSpec[blob.keyPath] = blob.data;
17023
- }
17024
- tx.table(item.tableName).update(item.primaryKey, (obj) => {
17025
- // Check that object still has the same unresolved blob refs before applying update (i.e. it hasn't been modified since we read it)
17026
- for (const blob of item.resolvedBlobs) {
17027
- // 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.
17028
- const currentValue = Dexie.getByKeyPath(obj, blob.keyPath);
17029
- if (currentValue === undefined) {
17030
- // Blob property was removed - skip updating this blob
17031
- continue;
17032
- }
17033
- if (!isBlobRef(currentValue)) {
17034
- // Blob property was modified to a non-blob-ref value - skip updating this blob
17035
- continue;
17036
- }
17037
- if (currentValue.ref !== blob.ref) {
17038
- // Blob property was modified - skip updating this blob
17039
- return; // Stop. Another items has been queued to fully fix the object.
17040
- }
17041
- Dexie.setByKeyPath(obj, blob.keyPath, blob.data);
17042
- }
17043
- delete obj._hasBlobRefs; // Clear the _hasBlobRefs marker if all refs was resolved.
17044
- });
17045
- })
17046
- .catch((error) => {
17047
- console.error(`Error saving resolved blobs on ${item.tableName}:${item.primaryKey}:`, error);
17048
- })
17049
- .finally(() => {
17050
- // Process next item in the queue
17051
- return this.processQueue();
17052
- });
17053
- }
17054
- }
17055
-
17056
17205
  /**
17057
17206
  * DBCore Middleware for resolving BlobRefs on read
17058
17207
  *
@@ -17063,10 +17212,11 @@
17063
17212
  * Uses Dexie.waitFor() only for explicit rw transactions to keep them alive.
17064
17213
  * For readonly or implicit transactions, resolves directly (no waitFor needed).
17065
17214
  *
17066
- * Resolved blobs are queued for saving via BlobSavingQueue, which uses
17067
- * setTimeout(fn, 0) to completely isolate from Dexie's transaction context.
17068
- * Each blob is saved atomically using Table.update() with its keyPath to
17069
- * avoid race conditions with other property changes.
17215
+ * Resolved blobs are persisted via db.blobDownloadTracker.enqueueSave(),
17216
+ * which internally uses a queue that runs in a fresh JS task to completely
17217
+ * isolate from Dexie's transaction context. Each blob is saved atomically
17218
+ * using Table.update() with its keyPath to avoid race conditions with other
17219
+ * property changes.
17070
17220
  *
17071
17221
  * Blob downloads use Authorization header (same as sync) via the server
17072
17222
  * proxy endpoint: GET /blob/{ref}
@@ -17077,8 +17227,6 @@
17077
17227
  name: 'blobResolve',
17078
17228
  level: 2, // Run above cache (0) and other middlewares (1) to resolve BlobRefs from cached data
17079
17229
  create(downlevelDatabase) {
17080
- // Create a single queue instance for this database
17081
- const blobSavingQueue = new BlobSavingQueue(db);
17082
17230
  return Object.assign(Object.assign({}, downlevelDatabase), { table(tableName) {
17083
17231
  var _a;
17084
17232
  if (!db.cloud) {
@@ -17099,7 +17247,7 @@
17099
17247
  }
17100
17248
  return downlevelTable.get(req).then((result) => {
17101
17249
  if (result && hasUnresolvedBlobRefs(result)) {
17102
- return resolveAndSave(downlevelTable, req.trans, req.key, result, blobSavingQueue, db);
17250
+ return resolveAndSave(downlevelTable, req.trans, req.key, result, db);
17103
17251
  }
17104
17252
  return result;
17105
17253
  });
@@ -17116,7 +17264,7 @@
17116
17264
  return results;
17117
17265
  return Dexie.Promise.all(results.map((result, index) => {
17118
17266
  if (result && hasUnresolvedBlobRefs(result)) {
17119
- return resolveAndSave(downlevelTable, req.trans, req.keys[index], result, blobSavingQueue, db);
17267
+ return resolveAndSave(downlevelTable, req.trans, req.keys[index], result, db);
17120
17268
  }
17121
17269
  return result;
17122
17270
  }));
@@ -17136,7 +17284,7 @@
17136
17284
  return result;
17137
17285
  return Dexie.Promise.all(result.result.map((item) => {
17138
17286
  if (item && hasUnresolvedBlobRefs(item)) {
17139
- return resolveAndSave(downlevelTable, req.trans, undefined, item, blobSavingQueue, db);
17287
+ return resolveAndSave(downlevelTable, req.trans, undefined, item, db);
17140
17288
  }
17141
17289
  return item;
17142
17290
  })).then((resolved) => (Object.assign(Object.assign({}, result), { result: resolved })));
@@ -17154,7 +17302,7 @@
17154
17302
  return cursor; // No values requested, so no resolution needed
17155
17303
  if (!dbUrl)
17156
17304
  return cursor; // No database URL configured, can't resolve blobs
17157
- return createBlobResolvingCursor(cursor, downlevelTable, blobSavingQueue, db);
17305
+ return createBlobResolvingCursor(cursor, downlevelTable, db);
17158
17306
  });
17159
17307
  } });
17160
17308
  } });
@@ -17171,7 +17319,7 @@
17171
17319
  * Returns the cursor synchronously. Resolution happens in start() before
17172
17320
  * each onNext callback, ensuring cursor.value is always available.
17173
17321
  */
17174
- function createBlobResolvingCursor(cursor, table, blobSavingQueue, db) {
17322
+ function createBlobResolvingCursor(cursor, table, db) {
17175
17323
  // Create wrapped cursor using Object.create() - inherits everything.
17176
17324
  // Important: .key and .primaryKey must be explicitly overridden with
17177
17325
  // closure-based getters to prevent native IDBCursorWithValue getters from
@@ -17179,11 +17327,15 @@
17179
17327
  // throws "Illegal invocation" in Chrome 146+.
17180
17328
  const wrappedCursor = Object.create(cursor, {
17181
17329
  key: {
17182
- get() { return cursor.key; },
17330
+ get() {
17331
+ return cursor.key;
17332
+ },
17183
17333
  configurable: true,
17184
17334
  },
17185
17335
  primaryKey: {
17186
- get() { return cursor.primaryKey; },
17336
+ get() {
17337
+ return cursor.primaryKey;
17338
+ },
17187
17339
  configurable: true,
17188
17340
  },
17189
17341
  value: {
@@ -17201,7 +17353,7 @@
17201
17353
  onNext();
17202
17354
  return;
17203
17355
  }
17204
- resolveAndSave(table, cursor.trans, cursor.primaryKey, rawValue, blobSavingQueue, db, true).then((resolved) => {
17356
+ resolveAndSave(table, cursor.trans, cursor.primaryKey, rawValue, db, true).then((resolved) => {
17205
17357
  wrappedCursor.value = resolved;
17206
17358
  onNext();
17207
17359
  }, (err) => {
@@ -17229,7 +17381,7 @@
17229
17381
  * Returns Dexie.Promise to preserve PSD context.
17230
17382
  */
17231
17383
  function resolveAndSave(table, trans, pKey, // optional. If missing, tries to extract from object using primary key path
17232
- obj, blobSavingQueue, db, isCursorValue = false // Flag to indicate if we're resolving a cursor value (which may not have a primary key)
17384
+ obj, db, isCursorValue = false // Flag to indicate if we're resolving a cursor value (which may not have a primary key)
17233
17385
  ) {
17234
17386
  var _a;
17235
17387
  try {
@@ -17266,21 +17418,19 @@
17266
17418
  ? Dexie.getByKeyPath(obj, primaryKey.keyPath)
17267
17419
  : undefined;
17268
17420
  if (key !== undefined) {
17269
- // Queue each resolved blob individually for atomic update
17270
- // This uses setTimeout(fn, 0) to completely isolate from
17271
- // Dexie's transaction context (avoids inheriting PSD)
17272
- if (isReadonly) {
17273
- blobSavingQueue.saveBlobs(table.name, key, resolvedBlobs);
17274
- }
17275
- else {
17276
- // For rw transactions, we can save directly without queueing
17277
- // since we're still in the same transaction context
17278
- table
17279
- .mutate({ type: 'put', keys: [key], values: [resolved], trans })
17280
- .catch((err) => {
17281
- console.error(`Failed to save resolved blob on ${table.name}:${key}:`, err);
17282
- });
17283
- }
17421
+ // Hand off persistence to the tracker. The tracker owns an
17422
+ // internal save-queue that runs in a fresh JS task (setTimeout 0)
17423
+ // completely outside any PSD context, so opening a Dexie rw
17424
+ // transaction there is always safe regardless of the calling
17425
+ // context. The tracker also keeps the in-flight download cache
17426
+ // alive until the save completes, so concurrent readers piggyback
17427
+ // on the already-downloaded data instead of refetching.
17428
+ db.blobDownloadTracker.enqueueSave(table.name, key, resolvedBlobs);
17429
+ }
17430
+ else if (resolvedBlobs.length > 0) {
17431
+ // No primary key — we can't persist. Release the in-flight cache
17432
+ // entries explicitly so they don't leak.
17433
+ db.blobDownloadTracker.releaseRefs(resolvedBlobs.map((b) => b.ref));
17284
17434
  }
17285
17435
  return resolved;
17286
17436
  })
@@ -18113,8 +18263,7 @@
18113
18263
  throw new Error(`No database URL to connect WebSocket to`);
18114
18264
  }
18115
18265
  const readyForChangesMessage = db.messageConsumer.readyToServe.pipe(operators.filter((isReady) => isReady), // When consumer is ready for new messages, produce such a message to inform server about it
18116
- operators.switchMap(() => db.cloud.persistedSyncState.pipe(operators.filter((syncState) => !!(syncState && syncState.serverRevision)), operators.take(1))), // Wait reactively for syncState with serverRevision (avoids race with logout/re-sync)
18117
- operators.switchMap((syncState) => __awaiter(this, void 0, void 0, function* () {
18266
+ operators.switchMap(() => db.getPersistedSyncState()), operators.filter((syncState) => !!(syncState && syncState.serverRevision)), operators.switchMap((syncState) => __awaiter(this, void 0, void 0, function* () {
18118
18267
  return ({
18119
18268
  // Produce the message to trigger server to send us new messages to consume:
18120
18269
  type: 'ready',
@@ -19742,7 +19891,7 @@
19742
19891
  const downloading$ = createDownloadingState();
19743
19892
  dexie.cloud = {
19744
19893
  // @ts-ignore
19745
- version: "4.4.10",
19894
+ version: "4.4.12",
19746
19895
  options: Object.assign({}, DEFAULT_OPTIONS),
19747
19896
  schema: null,
19748
19897
  get currentUserId() {
@@ -20187,7 +20336,7 @@
20187
20336
  }
20188
20337
  }
20189
20338
  // @ts-ignore
20190
- dexieCloud.version = "4.4.10";
20339
+ dexieCloud.version = "4.4.12";
20191
20340
  Dexie.Cloud = dexieCloud;
20192
20341
 
20193
20342
  exports.default = dexieCloud;