dexie-cloud-addon 4.4.11 → 4.4.13

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.13, Wed May 27 2026
12
12
  *
13
13
  * https://dexie.org
14
14
  *
@@ -4217,14 +4217,16 @@
4217
4217
  }
4218
4218
  break;
4219
4219
  case 'update':
4220
- if (!primaryKey.outbound && primaryKey.keyPath) {
4220
+ if (!primaryKey.outbound &&
4221
+ primaryKey.keyPath &&
4222
+ typeof primaryKey.keyPath === 'string') {
4221
4223
  // The primary key should never be part of an updateSpec — it cannot change
4222
4224
  // and is already communicated via the operation's keys array.
4223
4225
  // For private singleton IDs (e.g. "#key:userId" on server, "#key" on client),
4224
4226
  // the encoded server-side key may leak into the changeSpec via getObjectDiff().
4225
4227
  // Strip it here unconditionally as a defensive measure.
4226
4228
  for (const changeSpec of mut.changeSpecs) {
4227
- Dexie.delByKeyPath(changeSpec, primaryKey.keyPath);
4229
+ delete changeSpec[primaryKey.keyPath];
4228
4230
  }
4229
4231
  }
4230
4232
  yield bulkUpdate(table, keys, mut.changeSpecs);
@@ -13623,7 +13625,7 @@
13623
13625
  *
13624
13626
  * ==========================================================================
13625
13627
  *
13626
- * Version 4.4.0, Sun Apr 19 2026
13628
+ * Version 4.4.0, Wed May 27 2026
13627
13629
  *
13628
13630
  * https://dexie.org
13629
13631
  *
@@ -15231,22 +15233,208 @@
15231
15233
  }
15232
15234
 
15233
15235
  /**
15234
- * Deduplicates in-flight blob downloads.
15236
+ * BlobSavingQueue - Queues resolved blobs for saving back to IndexedDB.
15235
15237
  *
15236
- * Both the blob-resolve middleware and the eager blob downloader may
15237
- * try to fetch the same blob concurrently. This tracker ensures each
15238
- * unique blob ref is only downloaded once — subsequent requests for
15239
- * the same ref piggyback on the existing promise.
15238
+ * This is an internal collaborator of BlobDownloadTracker and is not
15239
+ * intended to be used directly by middleware or other code. See
15240
+ * BlobDownloadTracker.enqueueSave().
15240
15241
  *
15241
- * Instantiate once per DexieCloudDB.
15242
+ * Uses setTimeout(fn, 0) instead of queueMicrotask to completely isolate
15243
+ * from Dexie's Promise.PSD context. This prevents the save operation
15244
+ * from inheriting any ongoing transaction.
15245
+ *
15246
+ * Each blob is saved atomically using downCore transaction with the specific
15247
+ * keyPath to avoid race conditions with other property changes.
15248
+ */
15249
+ class BlobSavingQueue {
15250
+ constructor(db, onPersisted) {
15251
+ this.queue = [];
15252
+ this.isProcessing = false;
15253
+ this.drainResolvers = [];
15254
+ this.db = db;
15255
+ this.onPersisted = onPersisted;
15256
+ }
15257
+ /**
15258
+ * Queue a resolved blob for saving.
15259
+ * Only the specific blob property will be updated atomically.
15260
+ */
15261
+ saveBlobs(tableName, primaryKey, resolvedBlobs) {
15262
+ this.queue.push({
15263
+ tableName,
15264
+ primaryKey,
15265
+ resolvedBlobs,
15266
+ });
15267
+ this.startConsumer();
15268
+ }
15269
+ /**
15270
+ * Returns a promise that resolves when the queue is empty AND no item
15271
+ * is currently being processed. Used by callers that need to know when
15272
+ * all previously enqueued saves have been persisted to IndexedDB before
15273
+ * making decisions based on the on-disk state (e.g., the eager blob
15274
+ * downloader looping over `_hasBlobRefs=1` rows in chunks).
15275
+ *
15276
+ * Note: New work enqueued AFTER drain() is called does NOT extend the
15277
+ * wait. Callers that race against concurrent producers should treat the
15278
+ * returned promise as "queue was empty at some point after this call".
15279
+ */
15280
+ drain() {
15281
+ if (!this.isProcessing && this.queue.length === 0) {
15282
+ return Promise.resolve();
15283
+ }
15284
+ return new Promise((resolve) => {
15285
+ this.drainResolvers.push(resolve);
15286
+ });
15287
+ }
15288
+ /**
15289
+ * Start the consumer if not already processing.
15290
+ * Uses setTimeout(fn, 0) to completely break out of any
15291
+ * Dexie transaction context (Promise.PSD).
15292
+ */
15293
+ startConsumer() {
15294
+ if (this.isProcessing)
15295
+ return;
15296
+ this.isProcessing = true;
15297
+ // Use setTimeout to completely isolate from Dexie's PSD context
15298
+ // queueMicrotask would risk inheriting the current transaction
15299
+ setTimeout(() => {
15300
+ this.processQueue();
15301
+ }, 0);
15302
+ }
15303
+ /**
15304
+ * Process all queued blobs.
15305
+ * Runs in a completely isolated context (no inherited transaction).
15306
+ * Uses atomic updates to avoid race conditions.
15307
+ */
15308
+ processQueue() {
15309
+ const item = this.queue.shift();
15310
+ if (!item) {
15311
+ this.isProcessing = false;
15312
+ // Fire any pending drain() waiters. New saveBlobs() calls that
15313
+ // arrive after this point will start a fresh processing cycle
15314
+ // and have their own drain() semantics.
15315
+ const resolvers = this.drainResolvers;
15316
+ if (resolvers.length > 0) {
15317
+ this.drainResolvers = [];
15318
+ for (const resolve of resolvers)
15319
+ resolve();
15320
+ }
15321
+ return;
15322
+ }
15323
+ // Atomic update of just the blob property
15324
+ this.db
15325
+ .transaction('rw', item.tableName, (tx) => {
15326
+ const trans = tx.idbtrans;
15327
+ trans.disableChangeTracking = true; // Don't regard this as a change for sync purposes
15328
+ trans.disableAccessControl = true; // Bypass any access control checks since this is an internal operation
15329
+ trans.disableBlobResolve = true; // Custom flag to skip blob resolve middleware during this transaction
15330
+ const updateSpec = {};
15331
+ for (const blob of item.resolvedBlobs) {
15332
+ updateSpec[blob.keyPath] = blob.data;
15333
+ }
15334
+ tx.table(item.tableName).update(item.primaryKey, (obj) => {
15335
+ // Check that object still has the same unresolved blob refs before applying update (i.e. it hasn't been modified since we read it)
15336
+ for (const blob of item.resolvedBlobs) {
15337
+ // 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.
15338
+ const currentValue = Dexie.getByKeyPath(obj, blob.keyPath);
15339
+ if (currentValue === undefined) {
15340
+ // Blob property was removed - skip updating this blob
15341
+ continue;
15342
+ }
15343
+ if (!isBlobRef(currentValue)) {
15344
+ // Blob property was modified to a non-blob-ref value - skip updating this blob
15345
+ continue;
15346
+ }
15347
+ if (currentValue.ref !== blob.ref) {
15348
+ // Blob property was modified - skip updating this blob
15349
+ return; // Stop. Another items has been queued to fully fix the object.
15350
+ }
15351
+ Dexie.setByKeyPath(obj, blob.keyPath, blob.data);
15352
+ }
15353
+ delete obj._hasBlobRefs; // Clear the _hasBlobRefs marker if all refs was resolved.
15354
+ });
15355
+ // Note: we intentionally do NOT clear trans.mutatedParts here.
15356
+ // Letting the normal mutation signal through means the
15357
+ // blobProgress liveQuery (and any user-defined liveQuery that
15358
+ // depends on the resolved fields) wakes up and reflects progress
15359
+ // as blobs land in IndexedDB.
15360
+ })
15361
+ .catch((error) => {
15362
+ console.error(`Error saving resolved blobs on ${item.tableName}:${item.primaryKey}:`, error);
15363
+ })
15364
+ .finally(() => {
15365
+ // At this point, the transaction has completed (either successfully or with error),
15366
+ // and the blobs have been saved (or failed to save).
15367
+ // Notify the owner (BlobDownloadTracker) so it can release the
15368
+ // in-flight download cache entries for these refs. The cache was
15369
+ // kept alive until now to maximize reuse while the blob was still
15370
+ // in-flight (downloading or queued for save).
15371
+ this.onPersisted(item.resolvedBlobs.map((b) => b.ref));
15372
+ // Process next item in the queue
15373
+ return this.processQueue();
15374
+ });
15375
+ }
15376
+ }
15377
+
15378
+ /**
15379
+ * Owns the full lifecycle of downloaded blobs:
15380
+ * 1. Deduplicates concurrent downloads for the same ref.
15381
+ * 2. Bounds the number of concurrent network fetches (MAX_CONCURRENT)
15382
+ * so that ad-hoc reads can't starve the HTTP connection pool. Calls
15383
+ * beyond the cap queue in FIFO order as slots free. The slot is held
15384
+ * only for the duration of the fetch — NOT until persistence — to
15385
+ * avoid deadlocks when a single object contains more blob refs than
15386
+ * MAX_CONCURRENT (a sequential resolver would otherwise hold every
15387
+ * slot itself while waiting for the next).
15388
+ * 3. Keeps the in-flight promise alive after the network fetch completes,
15389
+ * until the blob has been persisted back to IndexedDB. This way,
15390
+ * readers that ask for the same ref while it is queued for saving
15391
+ * can piggyback on the existing promise instead of refetching.
15392
+ * In-flight membership and slot ownership are independent: a piggyback
15393
+ * reader consumes neither a slot nor extra memory beyond the existing
15394
+ * cached Uint8Array.
15395
+ * 4. Persists resolved blobs via an internal BlobSavingQueue, and
15396
+ * releases the in-flight entry when persistence completes.
15397
+ *
15398
+ * Both the blob-resolve middleware and the eager blob downloader use this
15399
+ * tracker. Instantiate once per DexieCloudDB.
15242
15400
  */
15401
+ /**
15402
+ * Maximum number of concurrent blob fetches.
15403
+ *
15404
+ * Historically 6 to match the HTTP/1.1 same-origin connection cap that
15405
+ * browsers enforce. With HTTP/2 (the typical transport for Dexie Cloud
15406
+ * today) many streams multiplex over a single TCP connection, so the
15407
+ * old cap is overly conservative. 10 is a modest bump that still keeps
15408
+ * memory pressure (in-flight Uint8Arrays) and server load bounded.
15409
+ * Can be made configurable via DexieCloudOptions if a real need arises.
15410
+ */
15411
+ const MAX_CONCURRENT = 10;
15243
15412
  class BlobDownloadTracker {
15244
15413
  constructor(db) {
15245
15414
  this.inFlight = new Map();
15415
+ this.activeFetches = 0;
15416
+ this.waiting = [];
15246
15417
  this.db = db;
15418
+ this.savingQueue = new BlobSavingQueue(db, (refs) => {
15419
+ // Called by the queue when a save transaction has completed
15420
+ // (regardless of success). Drop the in-flight cache entries now —
15421
+ // any future reader will go through IndexedDB instead.
15422
+ for (const ref of refs) {
15423
+ this.inFlight.delete(ref);
15424
+ }
15425
+ });
15247
15426
  }
15248
15427
  /**
15249
- * Download a blob, deduplicating concurrent requests for the same ref.
15428
+ * Download a blob, deduplicating concurrent requests for the same ref
15429
+ * and respecting the global fetch concurrency cap.
15430
+ *
15431
+ * Lifecycle:
15432
+ * - Slot is acquired before the fetch and released as soon as the
15433
+ * fetch settles (success or failure).
15434
+ * - The in-flight entry survives a successful fetch and lives on
15435
+ * until persistence completes (via enqueueSave) or releaseRefs
15436
+ * is called. On fetch failure, the entry is removed immediately
15437
+ * so a future call can retry.
15250
15438
  *
15251
15439
  * @param blobRef - The BlobRef to download
15252
15440
  * @param dbUrl - Base URL for the database (e.g., 'https://mydb.dexie.cloud')
@@ -15254,45 +15442,103 @@
15254
15442
  download(blobRef, dbUrl) {
15255
15443
  let promise = this.inFlight.get(blobRef.ref);
15256
15444
  if (!promise) {
15257
- promise = loadCachedAccessToken(this.db)
15258
- .then((accessToken) => {
15259
- // accessToken may be null for anonymous/unauthenticated users.
15260
- // Public realm blobs (rlm-public) are accessible without auth.
15261
- // downloadBlob will omit the Authorization header when token is null.
15262
- return downloadBlob(blobRef, dbUrl, accessToken);
15263
- })
15264
- .finally(() => this.inFlight.delete(blobRef.ref));
15265
- // When the promise settles (either fulfilled or rejected), remove it from the in-flight map
15445
+ promise = this.acquireSlot()
15446
+ .then(() => this.downloadBlob(blobRef, dbUrl).finally(() => this.releaseSlot()))
15447
+ .catch((err) => {
15448
+ // On error, remove immediately so a future call can retry.
15449
+ // (Slot already released by the .finally above.)
15450
+ this.inFlight.delete(blobRef.ref);
15451
+ throw err;
15452
+ });
15266
15453
  this.inFlight.set(blobRef.ref, promise);
15267
15454
  }
15268
15455
  return promise;
15269
15456
  }
15270
- }
15271
- /**
15272
- * Download blob data from server via proxy endpoint.
15273
- * Uses auth header for authentication (same as sync).
15274
- * When accessToken is null, the request is made without Authorization header —
15275
- * this allows downloading blobs from public realms (rlm-public) for
15276
- * unauthenticated users.
15277
- *
15278
- * @param blobRef - The BlobRef to download
15279
- * @param dbUrl - Base URL for the database (e.g., 'https://mydb.dexie.cloud')
15280
- * @param accessToken - Access token for authentication, or null for anonymous access
15281
- */
15282
- function downloadBlob(blobRef, dbUrl, accessToken) {
15283
- return __awaiter(this, void 0, void 0, function* () {
15284
- const downloadUrl = `${dbUrl}/blob/${blobRef.ref}`;
15285
- const headers = {};
15286
- if (accessToken) {
15287
- headers['Authorization'] = `Bearer ${accessToken}`;
15457
+ /**
15458
+ * Queue resolved blobs for persisting back to IndexedDB.
15459
+ * When the save transaction completes, the corresponding in-flight
15460
+ * entries are released.
15461
+ */
15462
+ enqueueSave(tableName, primaryKey, resolvedBlobs) {
15463
+ this.savingQueue.saveBlobs(tableName, primaryKey, resolvedBlobs);
15464
+ }
15465
+ /**
15466
+ * Wait until all previously enqueued saves have been persisted to
15467
+ * IndexedDB. Used by callers that need to make decisions based on
15468
+ * on-disk state — e.g., the eager downloader looping over rows with
15469
+ * `_hasBlobRefs=1` in chunks, where each iteration must see the
15470
+ * previous chunk's writes before re-querying.
15471
+ *
15472
+ * New saves enqueued AFTER drainPendingSaves() is called do NOT extend
15473
+ * the wait.
15474
+ */
15475
+ drainPendingSaves() {
15476
+ return this.savingQueue.drain();
15477
+ }
15478
+ /**
15479
+ * Release in-flight entries without going through the internal saving
15480
+ * queue. Used when the caller persists the blobs itself, or when no
15481
+ * primary key was available and the data won't be persisted at all.
15482
+ */
15483
+ releaseRefs(refs) {
15484
+ for (const ref of refs) {
15485
+ this.inFlight.delete(ref);
15288
15486
  }
15289
- const response = yield fetch(downloadUrl, { headers });
15290
- if (!response.ok) {
15291
- throw new Error(`Failed to download blob ${blobRef.ref}: ${response.status} ${response.statusText}`);
15487
+ }
15488
+ acquireSlot() {
15489
+ if (this.activeFetches < MAX_CONCURRENT) {
15490
+ this.activeFetches++;
15491
+ return Promise.resolve();
15292
15492
  }
15293
- const arrayBuffer = yield response.arrayBuffer();
15294
- return new Uint8Array(arrayBuffer);
15295
- });
15493
+ return new Promise((resolve) => {
15494
+ this.waiting.push(() => {
15495
+ this.activeFetches++;
15496
+ resolve();
15497
+ });
15498
+ });
15499
+ }
15500
+ releaseSlot() {
15501
+ this.activeFetches--;
15502
+ const next = this.waiting.shift();
15503
+ if (next)
15504
+ next();
15505
+ }
15506
+ /**
15507
+ * Download blob data from server via proxy endpoint.
15508
+ * Uses auth header for authentication (same as sync).
15509
+ * When accessToken is null, the request is made without Authorization header —
15510
+ * this allows downloading blobs from public realms (rlm-public) for
15511
+ * unauthenticated users.
15512
+ *
15513
+ * @param blobRef - The BlobRef to download
15514
+ * @param dbUrl - Base URL for the database (e.g., 'https://mydb.dexie.cloud')
15515
+ */
15516
+ downloadBlob(blobRef, dbUrl) {
15517
+ return __awaiter(this, void 0, void 0, function* () {
15518
+ const accessToken = yield loadCachedAccessToken(this.db);
15519
+ const downloadUrl = `${dbUrl}/blob/${blobRef.ref}`;
15520
+ const headers = {};
15521
+ if (accessToken) {
15522
+ // accessToken may be null for anonymous/unauthenticated users.
15523
+ // Public realm blobs (rlm-public) are accessible without auth.
15524
+ // downloadBlob will omit the Authorization header when token is null.
15525
+ headers['Authorization'] = `Bearer ${accessToken}`;
15526
+ }
15527
+ // cache: 'no-store' prevents the browser from storing this response in its
15528
+ // HTTP cache. The server sets a long Expires/Cache-Control header on blob
15529
+ // responses (blobs are immutable and content-addressed), which would
15530
+ // otherwise cause the browser to keep a copy in its disk cache in addition
15531
+ // to the copy we persist to IndexedDB — doubling storage for every blob.
15532
+ // Since we always persist to IndexedDB and subsequent reads go through
15533
+ // IndexedDB (never re-fetch), the browser cache copy is pure overhead.
15534
+ const response = yield fetch(downloadUrl, { headers, cache: 'no-store' });
15535
+ if (!response.ok) {
15536
+ throw new Error(`Failed to download blob ${blobRef.ref}: ${response.status} ${response.statusText}`);
15537
+ }
15538
+ const arrayBuffer = yield response.arrayBuffer();
15539
+ return new Uint8Array(arrayBuffer);
15540
+ });
15541
+ }
15296
15542
  }
15297
15543
 
15298
15544
  const wm$2 = new WeakMap();
@@ -15513,118 +15759,116 @@
15513
15759
  * Downloads unresolved blobs in the background when blobMode='eager'.
15514
15760
  * Called after sync completes to prefetch blobs for offline access.
15515
15761
  *
15762
+ * Strategy:
15763
+ * 1. Snapshot the primary keys of all rows currently flagged
15764
+ * `_hasBlobRefs=1` for each syncable table.
15765
+ * 2. Walk that key list in chunks via `bulkGet`. Each `bulkGet`
15766
+ * triggers the blob-resolve middleware, which does all the actual
15767
+ * work — downloading blobs (throttled and deduplicated by the
15768
+ * shared BlobDownloadTracker) and enqueueing them for persistence
15769
+ * via the internal save queue.
15770
+ *
15771
+ * This keeps a single, symmetric code path with normal application
15772
+ * reads, which is important when other middlewares are present
15773
+ * (e.g., a hypothetical encryption middleware): writes from the save
15774
+ * queue and reads from this loop both pass through the full middleware
15775
+ * stack, so on-disk representation stays consistent.
15776
+ *
15777
+ * Why a snapshot of primary keys (rather than re-querying the index)?
15778
+ * - Rows that get resolved by parallel application reads simply
15779
+ * disappear from the table contents we're about to re-fetch; the
15780
+ * middleware skips them since `_hasBlobRefs` is already cleared.
15781
+ * - Stuck rows (e.g., blob 404s) are naturally bypassed: we just
15782
+ * advance to the next chunk in the snapshot. No `seenKeys`
15783
+ * bookkeeping required.
15784
+ * - The snapshot is `string[]`-shaped for typical Dexie Cloud rows
15785
+ * (~36 bytes/UUID), so ~28K keys per MB. Acceptable for any
15786
+ * realistic dataset.
15787
+ *
15516
15788
  * Progress is tracked automatically via liveQuery in blobProgress.ts —
15517
15789
  * no manual progress reporting needed here.
15518
- */
15790
+ *
15791
+ * --- Throughput note ---
15792
+ * The chunk loop is sequential: bulkGet → wait for all downloads to
15793
+ * settle → next bulkGet. The save queue drains in the background and
15794
+ * does not block iteration (saves no longer need to be persisted before
15795
+ * the next iteration, since we don't re-query the index). For typical
15796
+ * blob sizes (10 KB – 10 MB) the network dominates total time. If
15797
+ * real-world profiling later shows the per-chunk fixed cost matters,
15798
+ * the next bulkGet could be kicked off in parallel with the current
15799
+ * one's middleware work — but we keep it simple until measurements
15800
+ * justify otherwise.
15801
+ */
15802
+ // One chunk = one full saturation of the tracker's concurrency semaphore.
15803
+ // Larger chunks would only buffer more downloaded Uint8Arrays in memory
15804
+ // while waiting for the save queue to persist them, without any throughput
15805
+ // benefit (the semaphore is the gate, not the bulkGet).
15806
+ const CHUNK_SIZE = MAX_CONCURRENT - 1; // Leave one slot for parallel app reads that might also trigger downloads
15519
15807
  /**
15520
15808
  * Download all unresolved blobs in the background.
15521
15809
  *
15522
15810
  * This is called when blobMode='eager' (default) after sync completes.
15523
- * BlobRef URLs are signed (SAS tokens) so no auth header needed.
15524
- *
15525
- * Each blob is saved atomically using Table.update() to avoid race conditions.
15526
15811
  */
15527
15812
  function downloadUnresolvedBlobs(db, downloading$, signal) {
15528
15813
  return __awaiter(this, void 0, void 0, function* () {
15529
- var _a;
15530
15814
  const debugLog = (msg) => console.debug(`[dexie-cloud] ${msg}`);
15531
15815
  debugLog('Eager download: Starting...');
15532
- // Scan for unresolved blobs
15533
- const syncedTables = getSyncableTables(db);
15534
- let hasWork = false;
15535
- for (const table of syncedTables) {
15536
- try {
15537
- const hasIndex = !!table.schema.idxByName['_hasBlobRefs'];
15538
- if (!hasIndex)
15539
- continue;
15540
- const count = yield table.where('_hasBlobRefs').equals(1).count();
15541
- if (count > 0) {
15542
- hasWork = true;
15543
- break;
15544
- }
15545
- }
15546
- catch (_b) {
15547
- // skip
15548
- }
15549
- }
15550
- if (!hasWork) {
15551
- debugLog('Eager download: No blobs remaining, exiting');
15552
- return;
15553
- }
15554
- setDownloadingState(downloading$, true);
15816
+ const syncedTables = getSyncableTables(db).filter((t) => t.schema.indexes.some((idx) => idx.name === '_hasBlobRefs'));
15817
+ let started = false;
15818
+ let totalProcessed = 0;
15555
15819
  try {
15556
- debugLog(`Eager download: Found ${syncedTables.length} syncable tables: ${syncedTables.map((t) => t.name).join(', ')}`);
15557
15820
  for (const table of syncedTables) {
15558
15821
  if (signal === null || signal === void 0 ? void 0 : signal.aborted)
15559
15822
  ;
15823
+ let keys;
15560
15824
  try {
15561
- // Check if table has _hasBlobRefs index
15562
- const hasIndex = table.schema.indexes.some((idx) => idx.name === '_hasBlobRefs');
15563
- if (!hasIndex)
15564
- continue;
15565
- // Query objects with _hasBlobRefs marker
15566
- const unresolvedObjects = yield table
15567
- .where('_hasBlobRefs')
15568
- .equals(1)
15569
- .toArray();
15570
- debugLog(`Eager download: Table ${table.name} has ${unresolvedObjects.length} unresolved objects`);
15571
- const databaseUrl = (_a = db.cloud.options) === null || _a === void 0 ? void 0 : _a.databaseUrl;
15572
- if (!databaseUrl)
15573
- throw new Error('Database URL is required to download blobs');
15574
- // Download up to MAX_CONCURRENT blobs in parallel
15575
- const MAX_CONCURRENT = 6;
15576
- const primaryKey = table.schema.primKey;
15577
- // Filter to actionable objects first
15578
- const pending = unresolvedObjects.filter((obj) => {
15579
- if (!hasUnresolvedBlobRefs(obj))
15580
- return false;
15581
- const key = primaryKey.keyPath
15582
- ? Dexie.getByKeyPath(obj, primaryKey.keyPath)
15583
- : undefined;
15584
- return key !== undefined;
15585
- });
15586
- // Process in parallel with concurrency limit
15587
- let i = 0;
15588
- const runNext = () => __awaiter(this, void 0, void 0, function* () {
15589
- while (i < pending.length) {
15590
- if (signal === null || signal === void 0 ? void 0 : signal.aborted)
15591
- ;
15592
- const obj = pending[i++];
15593
- const key = Dexie.getByKeyPath(obj, primaryKey.keyPath);
15594
- try {
15595
- // Refresh token per object — cheap (returns cached) but ensures
15596
- // we pick up renewed tokens during long download sessions.
15597
- const resolvedBlobs = [];
15598
- yield resolveAllBlobRefs(obj, databaseUrl, resolvedBlobs, '', new WeakMap(), db.blobDownloadTracker);
15599
- const updateSpec = {
15600
- _hasBlobRefs: undefined,
15601
- };
15602
- for (const blob of resolvedBlobs) {
15603
- updateSpec[blob.keyPath] = blob.data;
15604
- }
15605
- debugLog(`Eager download: Updating ${table.name}:${key} with ${resolvedBlobs.length} blobs`);
15606
- yield table.update(key, updateSpec);
15607
- // liveQuery in blobProgress.ts auto-detects this change
15608
- }
15609
- catch (err) {
15610
- console.error(`Failed to download blobs for ${table.name}:${key}:`, err);
15611
- }
15612
- }
15613
- });
15614
- // Launch up to MAX_CONCURRENT workers
15615
- const workers = [];
15616
- for (let w = 0; w < Math.min(MAX_CONCURRENT, pending.length); w++) {
15617
- workers.push(runNext());
15618
- }
15619
- yield Promise.all(workers);
15825
+ keys = yield table.where('_hasBlobRefs').equals(1).primaryKeys();
15620
15826
  }
15621
15827
  catch (err) {
15622
- // Table might not have _hasBlobRefs index or other issues - skip silently
15828
+ console.error(`Eager download: failed to list unresolved rows for ${table.name}:`, err);
15829
+ continue;
15830
+ }
15831
+ if (keys.length === 0)
15832
+ continue;
15833
+ if (!started) {
15834
+ setDownloadingState(downloading$, true);
15835
+ started = true;
15623
15836
  }
15837
+ debugLog(`Eager download: ${table.name} has ${keys.length} row(s)`);
15838
+ for (let i = 0; i < keys.length; i += CHUNK_SIZE) {
15839
+ if (signal === null || signal === void 0 ? void 0 : signal.aborted)
15840
+ ;
15841
+ const slice = keys.slice(i, i + CHUNK_SIZE);
15842
+ try {
15843
+ // bulkGet triggers the blob-resolve middleware for each row that
15844
+ // still has `_hasBlobRefs=1`. Rows already resolved by parallel
15845
+ // reads come back without the marker and the middleware no-ops.
15846
+ // Rows that have been deleted return `undefined` and are
15847
+ // likewise skipped.
15848
+ yield table.bulkGet(slice);
15849
+ }
15850
+ catch (err) {
15851
+ console.error(`Eager download: ${table.name} chunk failed:`, err);
15852
+ continue;
15853
+ }
15854
+ totalProcessed += slice.length;
15855
+ debugLog(`Eager download: ${table.name} ${Math.min(i + CHUNK_SIZE, keys.length)}/${keys.length}`);
15856
+ }
15857
+ }
15858
+ if (started) {
15859
+ // Make sure all middleware-enqueued saves have landed before we flip
15860
+ // `downloading$` to false — otherwise observers might see a "done"
15861
+ // signal while writes are still in flight.
15862
+ yield db.blobDownloadTracker.drainPendingSaves();
15863
+ debugLog(`Eager download: done (${totalProcessed} row(s) processed)`);
15864
+ }
15865
+ else {
15866
+ debugLog('Eager download: No blobs remaining, exiting');
15624
15867
  }
15625
15868
  }
15626
15869
  finally {
15627
- setDownloadingState(downloading$, false);
15870
+ if (started)
15871
+ setDownloadingState(downloading$, false);
15628
15872
  }
15629
15873
  });
15630
15874
  }
@@ -16960,99 +17204,6 @@
16960
17204
  };
16961
17205
  }
16962
17206
 
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
17207
  /**
17057
17208
  * DBCore Middleware for resolving BlobRefs on read
17058
17209
  *
@@ -17063,10 +17214,11 @@
17063
17214
  * Uses Dexie.waitFor() only for explicit rw transactions to keep them alive.
17064
17215
  * For readonly or implicit transactions, resolves directly (no waitFor needed).
17065
17216
  *
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.
17217
+ * Resolved blobs are persisted via db.blobDownloadTracker.enqueueSave(),
17218
+ * which internally uses a queue that runs in a fresh JS task to completely
17219
+ * isolate from Dexie's transaction context. Each blob is saved atomically
17220
+ * using Table.update() with its keyPath to avoid race conditions with other
17221
+ * property changes.
17070
17222
  *
17071
17223
  * Blob downloads use Authorization header (same as sync) via the server
17072
17224
  * proxy endpoint: GET /blob/{ref}
@@ -17077,8 +17229,6 @@
17077
17229
  name: 'blobResolve',
17078
17230
  level: 2, // Run above cache (0) and other middlewares (1) to resolve BlobRefs from cached data
17079
17231
  create(downlevelDatabase) {
17080
- // Create a single queue instance for this database
17081
- const blobSavingQueue = new BlobSavingQueue(db);
17082
17232
  return Object.assign(Object.assign({}, downlevelDatabase), { table(tableName) {
17083
17233
  var _a;
17084
17234
  if (!db.cloud) {
@@ -17099,7 +17249,7 @@
17099
17249
  }
17100
17250
  return downlevelTable.get(req).then((result) => {
17101
17251
  if (result && hasUnresolvedBlobRefs(result)) {
17102
- return resolveAndSave(downlevelTable, req.trans, req.key, result, blobSavingQueue, db);
17252
+ return resolveAndSave(downlevelTable, req.trans, req.key, result, db);
17103
17253
  }
17104
17254
  return result;
17105
17255
  });
@@ -17116,7 +17266,7 @@
17116
17266
  return results;
17117
17267
  return Dexie.Promise.all(results.map((result, index) => {
17118
17268
  if (result && hasUnresolvedBlobRefs(result)) {
17119
- return resolveAndSave(downlevelTable, req.trans, req.keys[index], result, blobSavingQueue, db);
17269
+ return resolveAndSave(downlevelTable, req.trans, req.keys[index], result, db);
17120
17270
  }
17121
17271
  return result;
17122
17272
  }));
@@ -17136,7 +17286,7 @@
17136
17286
  return result;
17137
17287
  return Dexie.Promise.all(result.result.map((item) => {
17138
17288
  if (item && hasUnresolvedBlobRefs(item)) {
17139
- return resolveAndSave(downlevelTable, req.trans, undefined, item, blobSavingQueue, db);
17289
+ return resolveAndSave(downlevelTable, req.trans, undefined, item, db);
17140
17290
  }
17141
17291
  return item;
17142
17292
  })).then((resolved) => (Object.assign(Object.assign({}, result), { result: resolved })));
@@ -17154,7 +17304,7 @@
17154
17304
  return cursor; // No values requested, so no resolution needed
17155
17305
  if (!dbUrl)
17156
17306
  return cursor; // No database URL configured, can't resolve blobs
17157
- return createBlobResolvingCursor(cursor, downlevelTable, blobSavingQueue, db);
17307
+ return createBlobResolvingCursor(cursor, downlevelTable, db);
17158
17308
  });
17159
17309
  } });
17160
17310
  } });
@@ -17171,7 +17321,7 @@
17171
17321
  * Returns the cursor synchronously. Resolution happens in start() before
17172
17322
  * each onNext callback, ensuring cursor.value is always available.
17173
17323
  */
17174
- function createBlobResolvingCursor(cursor, table, blobSavingQueue, db) {
17324
+ function createBlobResolvingCursor(cursor, table, db) {
17175
17325
  // Create wrapped cursor using Object.create() - inherits everything.
17176
17326
  // Important: .key and .primaryKey must be explicitly overridden with
17177
17327
  // closure-based getters to prevent native IDBCursorWithValue getters from
@@ -17179,11 +17329,15 @@
17179
17329
  // throws "Illegal invocation" in Chrome 146+.
17180
17330
  const wrappedCursor = Object.create(cursor, {
17181
17331
  key: {
17182
- get() { return cursor.key; },
17332
+ get() {
17333
+ return cursor.key;
17334
+ },
17183
17335
  configurable: true,
17184
17336
  },
17185
17337
  primaryKey: {
17186
- get() { return cursor.primaryKey; },
17338
+ get() {
17339
+ return cursor.primaryKey;
17340
+ },
17187
17341
  configurable: true,
17188
17342
  },
17189
17343
  value: {
@@ -17201,7 +17355,7 @@
17201
17355
  onNext();
17202
17356
  return;
17203
17357
  }
17204
- resolveAndSave(table, cursor.trans, cursor.primaryKey, rawValue, blobSavingQueue, db, true).then((resolved) => {
17358
+ resolveAndSave(table, cursor.trans, cursor.primaryKey, rawValue, db, true).then((resolved) => {
17205
17359
  wrappedCursor.value = resolved;
17206
17360
  onNext();
17207
17361
  }, (err) => {
@@ -17229,7 +17383,7 @@
17229
17383
  * Returns Dexie.Promise to preserve PSD context.
17230
17384
  */
17231
17385
  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)
17386
+ obj, db, isCursorValue = false // Flag to indicate if we're resolving a cursor value (which may not have a primary key)
17233
17387
  ) {
17234
17388
  var _a;
17235
17389
  try {
@@ -17266,21 +17420,19 @@
17266
17420
  ? Dexie.getByKeyPath(obj, primaryKey.keyPath)
17267
17421
  : undefined;
17268
17422
  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
- }
17423
+ // Hand off persistence to the tracker. The tracker owns an
17424
+ // internal save-queue that runs in a fresh JS task (setTimeout 0)
17425
+ // completely outside any PSD context, so opening a Dexie rw
17426
+ // transaction there is always safe regardless of the calling
17427
+ // context. The tracker also keeps the in-flight download cache
17428
+ // alive until the save completes, so concurrent readers piggyback
17429
+ // on the already-downloaded data instead of refetching.
17430
+ db.blobDownloadTracker.enqueueSave(table.name, key, resolvedBlobs);
17431
+ }
17432
+ else if (resolvedBlobs.length > 0) {
17433
+ // No primary key — we can't persist. Release the in-flight cache
17434
+ // entries explicitly so they don't leak.
17435
+ db.blobDownloadTracker.releaseRefs(resolvedBlobs.map((b) => b.ref));
17284
17436
  }
17285
17437
  return resolved;
17286
17438
  })
@@ -19570,7 +19722,7 @@
19570
19722
  const downloading$ = createDownloadingState();
19571
19723
  dexie.cloud = {
19572
19724
  // @ts-ignore
19573
- version: "4.4.11",
19725
+ version: "4.4.13",
19574
19726
  options: Object.assign({}, DEFAULT_OPTIONS),
19575
19727
  schema: null,
19576
19728
  get currentUserId() {
@@ -20015,7 +20167,7 @@
20015
20167
  }
20016
20168
  }
20017
20169
  // @ts-ignore
20018
- dexieCloud.version = "4.4.11";
20170
+ dexieCloud.version = "4.4.13";
20019
20171
  Dexie.Cloud = dexieCloud;
20020
20172
 
20021
20173
  // In case the SW lives for a while, let it reuse already opened connections: