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
  *
@@ -2917,14 +2917,16 @@ function applyServerChanges(changes, db) {
2917
2917
  }
2918
2918
  break;
2919
2919
  case 'update':
2920
- if (!primaryKey.outbound && primaryKey.keyPath) {
2920
+ if (!primaryKey.outbound &&
2921
+ primaryKey.keyPath &&
2922
+ typeof primaryKey.keyPath === 'string') {
2921
2923
  // The primary key should never be part of an updateSpec — it cannot change
2922
2924
  // and is already communicated via the operation's keys array.
2923
2925
  // For private singleton IDs (e.g. "#key:userId" on server, "#key" on client),
2924
2926
  // the encoded server-side key may leak into the changeSpec via getObjectDiff().
2925
2927
  // Strip it here unconditionally as a defensive measure.
2926
2928
  for (const changeSpec of mut.changeSpecs) {
2927
- Dexie.delByKeyPath(changeSpec, primaryKey.keyPath);
2929
+ delete changeSpec[primaryKey.keyPath];
2928
2930
  }
2929
2931
  }
2930
2932
  yield bulkUpdate(table, keys, mut.changeSpecs);
@@ -4263,22 +4265,208 @@ function MessagesFromServerConsumer(db) {
4263
4265
  }
4264
4266
 
4265
4267
  /**
4266
- * Deduplicates in-flight blob downloads.
4268
+ * BlobSavingQueue - Queues resolved blobs for saving back to IndexedDB.
4267
4269
  *
4268
- * Both the blob-resolve middleware and the eager blob downloader may
4269
- * try to fetch the same blob concurrently. This tracker ensures each
4270
- * unique blob ref is only downloaded once — subsequent requests for
4271
- * the same ref piggyback on the existing promise.
4270
+ * This is an internal collaborator of BlobDownloadTracker and is not
4271
+ * intended to be used directly by middleware or other code. See
4272
+ * BlobDownloadTracker.enqueueSave().
4272
4273
  *
4273
- * Instantiate once per DexieCloudDB.
4274
+ * Uses setTimeout(fn, 0) instead of queueMicrotask to completely isolate
4275
+ * from Dexie's Promise.PSD context. This prevents the save operation
4276
+ * from inheriting any ongoing transaction.
4277
+ *
4278
+ * Each blob is saved atomically using downCore transaction with the specific
4279
+ * keyPath to avoid race conditions with other property changes.
4280
+ */
4281
+ class BlobSavingQueue {
4282
+ constructor(db, onPersisted) {
4283
+ this.queue = [];
4284
+ this.isProcessing = false;
4285
+ this.drainResolvers = [];
4286
+ this.db = db;
4287
+ this.onPersisted = onPersisted;
4288
+ }
4289
+ /**
4290
+ * Queue a resolved blob for saving.
4291
+ * Only the specific blob property will be updated atomically.
4292
+ */
4293
+ saveBlobs(tableName, primaryKey, resolvedBlobs) {
4294
+ this.queue.push({
4295
+ tableName,
4296
+ primaryKey,
4297
+ resolvedBlobs,
4298
+ });
4299
+ this.startConsumer();
4300
+ }
4301
+ /**
4302
+ * Returns a promise that resolves when the queue is empty AND no item
4303
+ * is currently being processed. Used by callers that need to know when
4304
+ * all previously enqueued saves have been persisted to IndexedDB before
4305
+ * making decisions based on the on-disk state (e.g., the eager blob
4306
+ * downloader looping over `_hasBlobRefs=1` rows in chunks).
4307
+ *
4308
+ * Note: New work enqueued AFTER drain() is called does NOT extend the
4309
+ * wait. Callers that race against concurrent producers should treat the
4310
+ * returned promise as "queue was empty at some point after this call".
4311
+ */
4312
+ drain() {
4313
+ if (!this.isProcessing && this.queue.length === 0) {
4314
+ return Promise.resolve();
4315
+ }
4316
+ return new Promise((resolve) => {
4317
+ this.drainResolvers.push(resolve);
4318
+ });
4319
+ }
4320
+ /**
4321
+ * Start the consumer if not already processing.
4322
+ * Uses setTimeout(fn, 0) to completely break out of any
4323
+ * Dexie transaction context (Promise.PSD).
4324
+ */
4325
+ startConsumer() {
4326
+ if (this.isProcessing)
4327
+ return;
4328
+ this.isProcessing = true;
4329
+ // Use setTimeout to completely isolate from Dexie's PSD context
4330
+ // queueMicrotask would risk inheriting the current transaction
4331
+ setTimeout(() => {
4332
+ this.processQueue();
4333
+ }, 0);
4334
+ }
4335
+ /**
4336
+ * Process all queued blobs.
4337
+ * Runs in a completely isolated context (no inherited transaction).
4338
+ * Uses atomic updates to avoid race conditions.
4339
+ */
4340
+ processQueue() {
4341
+ const item = this.queue.shift();
4342
+ if (!item) {
4343
+ this.isProcessing = false;
4344
+ // Fire any pending drain() waiters. New saveBlobs() calls that
4345
+ // arrive after this point will start a fresh processing cycle
4346
+ // and have their own drain() semantics.
4347
+ const resolvers = this.drainResolvers;
4348
+ if (resolvers.length > 0) {
4349
+ this.drainResolvers = [];
4350
+ for (const resolve of resolvers)
4351
+ resolve();
4352
+ }
4353
+ return;
4354
+ }
4355
+ // Atomic update of just the blob property
4356
+ this.db
4357
+ .transaction('rw', item.tableName, (tx) => {
4358
+ const trans = tx.idbtrans;
4359
+ trans.disableChangeTracking = true; // Don't regard this as a change for sync purposes
4360
+ trans.disableAccessControl = true; // Bypass any access control checks since this is an internal operation
4361
+ trans.disableBlobResolve = true; // Custom flag to skip blob resolve middleware during this transaction
4362
+ const updateSpec = {};
4363
+ for (const blob of item.resolvedBlobs) {
4364
+ updateSpec[blob.keyPath] = blob.data;
4365
+ }
4366
+ tx.table(item.tableName).update(item.primaryKey, (obj) => {
4367
+ // Check that object still has the same unresolved blob refs before applying update (i.e. it hasn't been modified since we read it)
4368
+ for (const blob of item.resolvedBlobs) {
4369
+ // 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.
4370
+ const currentValue = Dexie.getByKeyPath(obj, blob.keyPath);
4371
+ if (currentValue === undefined) {
4372
+ // Blob property was removed - skip updating this blob
4373
+ continue;
4374
+ }
4375
+ if (!isBlobRef(currentValue)) {
4376
+ // Blob property was modified to a non-blob-ref value - skip updating this blob
4377
+ continue;
4378
+ }
4379
+ if (currentValue.ref !== blob.ref) {
4380
+ // Blob property was modified - skip updating this blob
4381
+ return; // Stop. Another items has been queued to fully fix the object.
4382
+ }
4383
+ Dexie.setByKeyPath(obj, blob.keyPath, blob.data);
4384
+ }
4385
+ delete obj._hasBlobRefs; // Clear the _hasBlobRefs marker if all refs was resolved.
4386
+ });
4387
+ // Note: we intentionally do NOT clear trans.mutatedParts here.
4388
+ // Letting the normal mutation signal through means the
4389
+ // blobProgress liveQuery (and any user-defined liveQuery that
4390
+ // depends on the resolved fields) wakes up and reflects progress
4391
+ // as blobs land in IndexedDB.
4392
+ })
4393
+ .catch((error) => {
4394
+ console.error(`Error saving resolved blobs on ${item.tableName}:${item.primaryKey}:`, error);
4395
+ })
4396
+ .finally(() => {
4397
+ // At this point, the transaction has completed (either successfully or with error),
4398
+ // and the blobs have been saved (or failed to save).
4399
+ // Notify the owner (BlobDownloadTracker) so it can release the
4400
+ // in-flight download cache entries for these refs. The cache was
4401
+ // kept alive until now to maximize reuse while the blob was still
4402
+ // in-flight (downloading or queued for save).
4403
+ this.onPersisted(item.resolvedBlobs.map((b) => b.ref));
4404
+ // Process next item in the queue
4405
+ return this.processQueue();
4406
+ });
4407
+ }
4408
+ }
4409
+
4410
+ /**
4411
+ * Owns the full lifecycle of downloaded blobs:
4412
+ * 1. Deduplicates concurrent downloads for the same ref.
4413
+ * 2. Bounds the number of concurrent network fetches (MAX_CONCURRENT)
4414
+ * so that ad-hoc reads can't starve the HTTP connection pool. Calls
4415
+ * beyond the cap queue in FIFO order as slots free. The slot is held
4416
+ * only for the duration of the fetch — NOT until persistence — to
4417
+ * avoid deadlocks when a single object contains more blob refs than
4418
+ * MAX_CONCURRENT (a sequential resolver would otherwise hold every
4419
+ * slot itself while waiting for the next).
4420
+ * 3. Keeps the in-flight promise alive after the network fetch completes,
4421
+ * until the blob has been persisted back to IndexedDB. This way,
4422
+ * readers that ask for the same ref while it is queued for saving
4423
+ * can piggyback on the existing promise instead of refetching.
4424
+ * In-flight membership and slot ownership are independent: a piggyback
4425
+ * reader consumes neither a slot nor extra memory beyond the existing
4426
+ * cached Uint8Array.
4427
+ * 4. Persists resolved blobs via an internal BlobSavingQueue, and
4428
+ * releases the in-flight entry when persistence completes.
4429
+ *
4430
+ * Both the blob-resolve middleware and the eager blob downloader use this
4431
+ * tracker. Instantiate once per DexieCloudDB.
4432
+ */
4433
+ /**
4434
+ * Maximum number of concurrent blob fetches.
4435
+ *
4436
+ * Historically 6 to match the HTTP/1.1 same-origin connection cap that
4437
+ * browsers enforce. With HTTP/2 (the typical transport for Dexie Cloud
4438
+ * today) many streams multiplex over a single TCP connection, so the
4439
+ * old cap is overly conservative. 10 is a modest bump that still keeps
4440
+ * memory pressure (in-flight Uint8Arrays) and server load bounded.
4441
+ * Can be made configurable via DexieCloudOptions if a real need arises.
4274
4442
  */
4443
+ const MAX_CONCURRENT = 10;
4275
4444
  class BlobDownloadTracker {
4276
4445
  constructor(db) {
4277
4446
  this.inFlight = new Map();
4447
+ this.activeFetches = 0;
4448
+ this.waiting = [];
4278
4449
  this.db = db;
4450
+ this.savingQueue = new BlobSavingQueue(db, (refs) => {
4451
+ // Called by the queue when a save transaction has completed
4452
+ // (regardless of success). Drop the in-flight cache entries now —
4453
+ // any future reader will go through IndexedDB instead.
4454
+ for (const ref of refs) {
4455
+ this.inFlight.delete(ref);
4456
+ }
4457
+ });
4279
4458
  }
4280
4459
  /**
4281
- * Download a blob, deduplicating concurrent requests for the same ref.
4460
+ * Download a blob, deduplicating concurrent requests for the same ref
4461
+ * and respecting the global fetch concurrency cap.
4462
+ *
4463
+ * Lifecycle:
4464
+ * - Slot is acquired before the fetch and released as soon as the
4465
+ * fetch settles (success or failure).
4466
+ * - The in-flight entry survives a successful fetch and lives on
4467
+ * until persistence completes (via enqueueSave) or releaseRefs
4468
+ * is called. On fetch failure, the entry is removed immediately
4469
+ * so a future call can retry.
4282
4470
  *
4283
4471
  * @param blobRef - The BlobRef to download
4284
4472
  * @param dbUrl - Base URL for the database (e.g., 'https://mydb.dexie.cloud')
@@ -4286,45 +4474,103 @@ class BlobDownloadTracker {
4286
4474
  download(blobRef, dbUrl) {
4287
4475
  let promise = this.inFlight.get(blobRef.ref);
4288
4476
  if (!promise) {
4289
- promise = loadCachedAccessToken(this.db)
4290
- .then((accessToken) => {
4291
- // accessToken may be null for anonymous/unauthenticated users.
4292
- // Public realm blobs (rlm-public) are accessible without auth.
4293
- // downloadBlob will omit the Authorization header when token is null.
4294
- return downloadBlob(blobRef, dbUrl, accessToken);
4295
- })
4296
- .finally(() => this.inFlight.delete(blobRef.ref));
4297
- // When the promise settles (either fulfilled or rejected), remove it from the in-flight map
4477
+ promise = this.acquireSlot()
4478
+ .then(() => this.downloadBlob(blobRef, dbUrl).finally(() => this.releaseSlot()))
4479
+ .catch((err) => {
4480
+ // On error, remove immediately so a future call can retry.
4481
+ // (Slot already released by the .finally above.)
4482
+ this.inFlight.delete(blobRef.ref);
4483
+ throw err;
4484
+ });
4298
4485
  this.inFlight.set(blobRef.ref, promise);
4299
4486
  }
4300
4487
  return promise;
4301
4488
  }
4302
- }
4303
- /**
4304
- * Download blob data from server via proxy endpoint.
4305
- * Uses auth header for authentication (same as sync).
4306
- * When accessToken is null, the request is made without Authorization header —
4307
- * this allows downloading blobs from public realms (rlm-public) for
4308
- * unauthenticated users.
4309
- *
4310
- * @param blobRef - The BlobRef to download
4311
- * @param dbUrl - Base URL for the database (e.g., 'https://mydb.dexie.cloud')
4312
- * @param accessToken - Access token for authentication, or null for anonymous access
4313
- */
4314
- function downloadBlob(blobRef, dbUrl, accessToken) {
4315
- return __awaiter(this, void 0, void 0, function* () {
4316
- const downloadUrl = `${dbUrl}/blob/${blobRef.ref}`;
4317
- const headers = {};
4318
- if (accessToken) {
4319
- headers['Authorization'] = `Bearer ${accessToken}`;
4489
+ /**
4490
+ * Queue resolved blobs for persisting back to IndexedDB.
4491
+ * When the save transaction completes, the corresponding in-flight
4492
+ * entries are released.
4493
+ */
4494
+ enqueueSave(tableName, primaryKey, resolvedBlobs) {
4495
+ this.savingQueue.saveBlobs(tableName, primaryKey, resolvedBlobs);
4496
+ }
4497
+ /**
4498
+ * Wait until all previously enqueued saves have been persisted to
4499
+ * IndexedDB. Used by callers that need to make decisions based on
4500
+ * on-disk state — e.g., the eager downloader looping over rows with
4501
+ * `_hasBlobRefs=1` in chunks, where each iteration must see the
4502
+ * previous chunk's writes before re-querying.
4503
+ *
4504
+ * New saves enqueued AFTER drainPendingSaves() is called do NOT extend
4505
+ * the wait.
4506
+ */
4507
+ drainPendingSaves() {
4508
+ return this.savingQueue.drain();
4509
+ }
4510
+ /**
4511
+ * Release in-flight entries without going through the internal saving
4512
+ * queue. Used when the caller persists the blobs itself, or when no
4513
+ * primary key was available and the data won't be persisted at all.
4514
+ */
4515
+ releaseRefs(refs) {
4516
+ for (const ref of refs) {
4517
+ this.inFlight.delete(ref);
4320
4518
  }
4321
- const response = yield fetch(downloadUrl, { headers });
4322
- if (!response.ok) {
4323
- throw new Error(`Failed to download blob ${blobRef.ref}: ${response.status} ${response.statusText}`);
4519
+ }
4520
+ acquireSlot() {
4521
+ if (this.activeFetches < MAX_CONCURRENT) {
4522
+ this.activeFetches++;
4523
+ return Promise.resolve();
4324
4524
  }
4325
- const arrayBuffer = yield response.arrayBuffer();
4326
- return new Uint8Array(arrayBuffer);
4327
- });
4525
+ return new Promise((resolve) => {
4526
+ this.waiting.push(() => {
4527
+ this.activeFetches++;
4528
+ resolve();
4529
+ });
4530
+ });
4531
+ }
4532
+ releaseSlot() {
4533
+ this.activeFetches--;
4534
+ const next = this.waiting.shift();
4535
+ if (next)
4536
+ next();
4537
+ }
4538
+ /**
4539
+ * Download blob data from server via proxy endpoint.
4540
+ * Uses auth header for authentication (same as sync).
4541
+ * When accessToken is null, the request is made without Authorization header —
4542
+ * this allows downloading blobs from public realms (rlm-public) for
4543
+ * unauthenticated users.
4544
+ *
4545
+ * @param blobRef - The BlobRef to download
4546
+ * @param dbUrl - Base URL for the database (e.g., 'https://mydb.dexie.cloud')
4547
+ */
4548
+ downloadBlob(blobRef, dbUrl) {
4549
+ return __awaiter(this, void 0, void 0, function* () {
4550
+ const accessToken = yield loadCachedAccessToken(this.db);
4551
+ const downloadUrl = `${dbUrl}/blob/${blobRef.ref}`;
4552
+ const headers = {};
4553
+ if (accessToken) {
4554
+ // accessToken may be null for anonymous/unauthenticated users.
4555
+ // Public realm blobs (rlm-public) are accessible without auth.
4556
+ // downloadBlob will omit the Authorization header when token is null.
4557
+ headers['Authorization'] = `Bearer ${accessToken}`;
4558
+ }
4559
+ // cache: 'no-store' prevents the browser from storing this response in its
4560
+ // HTTP cache. The server sets a long Expires/Cache-Control header on blob
4561
+ // responses (blobs are immutable and content-addressed), which would
4562
+ // otherwise cause the browser to keep a copy in its disk cache in addition
4563
+ // to the copy we persist to IndexedDB — doubling storage for every blob.
4564
+ // Since we always persist to IndexedDB and subsequent reads go through
4565
+ // IndexedDB (never re-fetch), the browser cache copy is pure overhead.
4566
+ const response = yield fetch(downloadUrl, { headers, cache: 'no-store' });
4567
+ if (!response.ok) {
4568
+ throw new Error(`Failed to download blob ${blobRef.ref}: ${response.status} ${response.statusText}`);
4569
+ }
4570
+ const arrayBuffer = yield response.arrayBuffer();
4571
+ return new Uint8Array(arrayBuffer);
4572
+ });
4573
+ }
4328
4574
  }
4329
4575
 
4330
4576
  const wm$2 = new WeakMap();
@@ -4545,118 +4791,116 @@ function findBlobRefs(obj) {
4545
4791
  * Downloads unresolved blobs in the background when blobMode='eager'.
4546
4792
  * Called after sync completes to prefetch blobs for offline access.
4547
4793
  *
4794
+ * Strategy:
4795
+ * 1. Snapshot the primary keys of all rows currently flagged
4796
+ * `_hasBlobRefs=1` for each syncable table.
4797
+ * 2. Walk that key list in chunks via `bulkGet`. Each `bulkGet`
4798
+ * triggers the blob-resolve middleware, which does all the actual
4799
+ * work — downloading blobs (throttled and deduplicated by the
4800
+ * shared BlobDownloadTracker) and enqueueing them for persistence
4801
+ * via the internal save queue.
4802
+ *
4803
+ * This keeps a single, symmetric code path with normal application
4804
+ * reads, which is important when other middlewares are present
4805
+ * (e.g., a hypothetical encryption middleware): writes from the save
4806
+ * queue and reads from this loop both pass through the full middleware
4807
+ * stack, so on-disk representation stays consistent.
4808
+ *
4809
+ * Why a snapshot of primary keys (rather than re-querying the index)?
4810
+ * - Rows that get resolved by parallel application reads simply
4811
+ * disappear from the table contents we're about to re-fetch; the
4812
+ * middleware skips them since `_hasBlobRefs` is already cleared.
4813
+ * - Stuck rows (e.g., blob 404s) are naturally bypassed: we just
4814
+ * advance to the next chunk in the snapshot. No `seenKeys`
4815
+ * bookkeeping required.
4816
+ * - The snapshot is `string[]`-shaped for typical Dexie Cloud rows
4817
+ * (~36 bytes/UUID), so ~28K keys per MB. Acceptable for any
4818
+ * realistic dataset.
4819
+ *
4548
4820
  * Progress is tracked automatically via liveQuery in blobProgress.ts —
4549
4821
  * no manual progress reporting needed here.
4822
+ *
4823
+ * --- Throughput note ---
4824
+ * The chunk loop is sequential: bulkGet → wait for all downloads to
4825
+ * settle → next bulkGet. The save queue drains in the background and
4826
+ * does not block iteration (saves no longer need to be persisted before
4827
+ * the next iteration, since we don't re-query the index). For typical
4828
+ * blob sizes (10 KB – 10 MB) the network dominates total time. If
4829
+ * real-world profiling later shows the per-chunk fixed cost matters,
4830
+ * the next bulkGet could be kicked off in parallel with the current
4831
+ * one's middleware work — but we keep it simple until measurements
4832
+ * justify otherwise.
4550
4833
  */
4834
+ // One chunk = one full saturation of the tracker's concurrency semaphore.
4835
+ // Larger chunks would only buffer more downloaded Uint8Arrays in memory
4836
+ // while waiting for the save queue to persist them, without any throughput
4837
+ // benefit (the semaphore is the gate, not the bulkGet).
4838
+ const CHUNK_SIZE = MAX_CONCURRENT - 1; // Leave one slot for parallel app reads that might also trigger downloads
4551
4839
  /**
4552
4840
  * Download all unresolved blobs in the background.
4553
4841
  *
4554
4842
  * This is called when blobMode='eager' (default) after sync completes.
4555
- * BlobRef URLs are signed (SAS tokens) so no auth header needed.
4556
- *
4557
- * Each blob is saved atomically using Table.update() to avoid race conditions.
4558
4843
  */
4559
4844
  function downloadUnresolvedBlobs(db, downloading$, signal) {
4560
4845
  return __awaiter(this, void 0, void 0, function* () {
4561
- var _a;
4562
4846
  const debugLog = (msg) => console.debug(`[dexie-cloud] ${msg}`);
4563
4847
  debugLog('Eager download: Starting...');
4564
- // Scan for unresolved blobs
4565
- const syncedTables = getSyncableTables(db);
4566
- let hasWork = false;
4567
- for (const table of syncedTables) {
4568
- try {
4569
- const hasIndex = !!table.schema.idxByName['_hasBlobRefs'];
4570
- if (!hasIndex)
4571
- continue;
4572
- const count = yield table.where('_hasBlobRefs').equals(1).count();
4573
- if (count > 0) {
4574
- hasWork = true;
4575
- break;
4576
- }
4577
- }
4578
- catch (_b) {
4579
- // skip
4580
- }
4581
- }
4582
- if (!hasWork) {
4583
- debugLog('Eager download: No blobs remaining, exiting');
4584
- return;
4585
- }
4586
- setDownloadingState(downloading$, true);
4848
+ const syncedTables = getSyncableTables(db).filter((t) => t.schema.indexes.some((idx) => idx.name === '_hasBlobRefs'));
4849
+ let started = false;
4850
+ let totalProcessed = 0;
4587
4851
  try {
4588
- debugLog(`Eager download: Found ${syncedTables.length} syncable tables: ${syncedTables.map((t) => t.name).join(', ')}`);
4589
4852
  for (const table of syncedTables) {
4590
4853
  if (signal === null || signal === void 0 ? void 0 : signal.aborted)
4591
4854
  ;
4855
+ let keys;
4592
4856
  try {
4593
- // Check if table has _hasBlobRefs index
4594
- const hasIndex = table.schema.indexes.some((idx) => idx.name === '_hasBlobRefs');
4595
- if (!hasIndex)
4596
- continue;
4597
- // Query objects with _hasBlobRefs marker
4598
- const unresolvedObjects = yield table
4599
- .where('_hasBlobRefs')
4600
- .equals(1)
4601
- .toArray();
4602
- debugLog(`Eager download: Table ${table.name} has ${unresolvedObjects.length} unresolved objects`);
4603
- const databaseUrl = (_a = db.cloud.options) === null || _a === void 0 ? void 0 : _a.databaseUrl;
4604
- if (!databaseUrl)
4605
- throw new Error('Database URL is required to download blobs');
4606
- // Download up to MAX_CONCURRENT blobs in parallel
4607
- const MAX_CONCURRENT = 6;
4608
- const primaryKey = table.schema.primKey;
4609
- // Filter to actionable objects first
4610
- const pending = unresolvedObjects.filter((obj) => {
4611
- if (!hasUnresolvedBlobRefs(obj))
4612
- return false;
4613
- const key = primaryKey.keyPath
4614
- ? Dexie.getByKeyPath(obj, primaryKey.keyPath)
4615
- : undefined;
4616
- return key !== undefined;
4617
- });
4618
- // Process in parallel with concurrency limit
4619
- let i = 0;
4620
- const runNext = () => __awaiter(this, void 0, void 0, function* () {
4621
- while (i < pending.length) {
4622
- if (signal === null || signal === void 0 ? void 0 : signal.aborted)
4623
- ;
4624
- const obj = pending[i++];
4625
- const key = Dexie.getByKeyPath(obj, primaryKey.keyPath);
4626
- try {
4627
- // Refresh token per object — cheap (returns cached) but ensures
4628
- // we pick up renewed tokens during long download sessions.
4629
- const resolvedBlobs = [];
4630
- yield resolveAllBlobRefs(obj, databaseUrl, resolvedBlobs, '', new WeakMap(), db.blobDownloadTracker);
4631
- const updateSpec = {
4632
- _hasBlobRefs: undefined,
4633
- };
4634
- for (const blob of resolvedBlobs) {
4635
- updateSpec[blob.keyPath] = blob.data;
4636
- }
4637
- debugLog(`Eager download: Updating ${table.name}:${key} with ${resolvedBlobs.length} blobs`);
4638
- yield table.update(key, updateSpec);
4639
- // liveQuery in blobProgress.ts auto-detects this change
4640
- }
4641
- catch (err) {
4642
- console.error(`Failed to download blobs for ${table.name}:${key}:`, err);
4643
- }
4644
- }
4645
- });
4646
- // Launch up to MAX_CONCURRENT workers
4647
- const workers = [];
4648
- for (let w = 0; w < Math.min(MAX_CONCURRENT, pending.length); w++) {
4649
- workers.push(runNext());
4650
- }
4651
- yield Promise.all(workers);
4857
+ keys = yield table.where('_hasBlobRefs').equals(1).primaryKeys();
4652
4858
  }
4653
4859
  catch (err) {
4654
- // Table might not have _hasBlobRefs index or other issues - skip silently
4860
+ console.error(`Eager download: failed to list unresolved rows for ${table.name}:`, err);
4861
+ continue;
4862
+ }
4863
+ if (keys.length === 0)
4864
+ continue;
4865
+ if (!started) {
4866
+ setDownloadingState(downloading$, true);
4867
+ started = true;
4868
+ }
4869
+ debugLog(`Eager download: ${table.name} has ${keys.length} row(s)`);
4870
+ for (let i = 0; i < keys.length; i += CHUNK_SIZE) {
4871
+ if (signal === null || signal === void 0 ? void 0 : signal.aborted)
4872
+ ;
4873
+ const slice = keys.slice(i, i + CHUNK_SIZE);
4874
+ try {
4875
+ // bulkGet triggers the blob-resolve middleware for each row that
4876
+ // still has `_hasBlobRefs=1`. Rows already resolved by parallel
4877
+ // reads come back without the marker and the middleware no-ops.
4878
+ // Rows that have been deleted return `undefined` and are
4879
+ // likewise skipped.
4880
+ yield table.bulkGet(slice);
4881
+ }
4882
+ catch (err) {
4883
+ console.error(`Eager download: ${table.name} chunk failed:`, err);
4884
+ continue;
4885
+ }
4886
+ totalProcessed += slice.length;
4887
+ debugLog(`Eager download: ${table.name} ${Math.min(i + CHUNK_SIZE, keys.length)}/${keys.length}`);
4655
4888
  }
4656
4889
  }
4890
+ if (started) {
4891
+ // Make sure all middleware-enqueued saves have landed before we flip
4892
+ // `downloading$` to false — otherwise observers might see a "done"
4893
+ // signal while writes are still in flight.
4894
+ yield db.blobDownloadTracker.drainPendingSaves();
4895
+ debugLog(`Eager download: done (${totalProcessed} row(s) processed)`);
4896
+ }
4897
+ else {
4898
+ debugLog('Eager download: No blobs remaining, exiting');
4899
+ }
4657
4900
  }
4658
4901
  finally {
4659
- setDownloadingState(downloading$, false);
4902
+ if (started)
4903
+ setDownloadingState(downloading$, false);
4660
4904
  }
4661
4905
  });
4662
4906
  }
@@ -5992,99 +6236,6 @@ function createMutationTrackingMiddleware({ currentUserObservable, db, }) {
5992
6236
  };
5993
6237
  }
5994
6238
 
5995
- /**
5996
- * BlobSavingQueue - Queues resolved blobs for saving back to IndexedDB
5997
- *
5998
- * Uses setTimeout(fn, 0) instead of queueMicrotask to completely isolate
5999
- * from Dexie's Promise.PSD context. This prevents the save operation
6000
- * from inheriting any ongoing transaction.
6001
- *
6002
- * Each blob is saved atomically using downCore transaction with the specific
6003
- * keyPath to avoid race conditions with other property changes.
6004
- */
6005
- class BlobSavingQueue {
6006
- constructor(db) {
6007
- this.queue = [];
6008
- this.isProcessing = false;
6009
- this.db = db;
6010
- }
6011
- /**
6012
- * Queue a resolved blob for saving.
6013
- * Only the specific blob property will be updated atomically.
6014
- */
6015
- saveBlobs(tableName, primaryKey, resolvedBlobs) {
6016
- this.queue.push({ tableName, primaryKey, resolvedBlobs });
6017
- this.startConsumer();
6018
- }
6019
- /**
6020
- * Start the consumer if not already processing.
6021
- * Uses setTimeout(fn, 0) to completely break out of any
6022
- * Dexie transaction context (Promise.PSD).
6023
- */
6024
- startConsumer() {
6025
- if (this.isProcessing)
6026
- return;
6027
- this.isProcessing = true;
6028
- // Use setTimeout to completely isolate from Dexie's PSD context
6029
- // queueMicrotask would risk inheriting the current transaction
6030
- setTimeout(() => {
6031
- this.processQueue();
6032
- }, 0);
6033
- }
6034
- /**
6035
- * Process all queued blobs.
6036
- * Runs in a completely isolated context (no inherited transaction).
6037
- * Uses atomic updates to avoid race conditions.
6038
- */
6039
- processQueue() {
6040
- const item = this.queue.shift();
6041
- if (!item) {
6042
- this.isProcessing = false;
6043
- return;
6044
- }
6045
- // Atomic update of just the blob property
6046
- this.db
6047
- .transaction('rw', item.tableName, (tx) => {
6048
- const trans = tx.idbtrans;
6049
- trans.disableChangeTracking = true; // Don't regard this as a change for sync purposes
6050
- trans.disableAccessControl = true; // Bypass any access control checks since this is an internal operation
6051
- trans.disableBlobResolve = true; // Custom flag to skip blob resolve middleware during this transaction
6052
- const updateSpec = {};
6053
- for (const blob of item.resolvedBlobs) {
6054
- updateSpec[blob.keyPath] = blob.data;
6055
- }
6056
- tx.table(item.tableName).update(item.primaryKey, (obj) => {
6057
- // Check that object still has the same unresolved blob refs before applying update (i.e. it hasn't been modified since we read it)
6058
- for (const blob of item.resolvedBlobs) {
6059
- // Verify atomicity - none of the blob properties has been modified since we read it. If any of them was modified, skip updating this item to avoid overwriting user changes.
6060
- const currentValue = Dexie.getByKeyPath(obj, blob.keyPath);
6061
- if (currentValue === undefined) {
6062
- // Blob property was removed - skip updating this blob
6063
- continue;
6064
- }
6065
- if (!isBlobRef(currentValue)) {
6066
- // Blob property was modified to a non-blob-ref value - skip updating this blob
6067
- continue;
6068
- }
6069
- if (currentValue.ref !== blob.ref) {
6070
- // Blob property was modified - skip updating this blob
6071
- return; // Stop. Another items has been queued to fully fix the object.
6072
- }
6073
- Dexie.setByKeyPath(obj, blob.keyPath, blob.data);
6074
- }
6075
- delete obj._hasBlobRefs; // Clear the _hasBlobRefs marker if all refs was resolved.
6076
- });
6077
- })
6078
- .catch((error) => {
6079
- console.error(`Error saving resolved blobs on ${item.tableName}:${item.primaryKey}:`, error);
6080
- })
6081
- .finally(() => {
6082
- // Process next item in the queue
6083
- return this.processQueue();
6084
- });
6085
- }
6086
- }
6087
-
6088
6239
  /**
6089
6240
  * DBCore Middleware for resolving BlobRefs on read
6090
6241
  *
@@ -6095,10 +6246,11 @@ class BlobSavingQueue {
6095
6246
  * Uses Dexie.waitFor() only for explicit rw transactions to keep them alive.
6096
6247
  * For readonly or implicit transactions, resolves directly (no waitFor needed).
6097
6248
  *
6098
- * Resolved blobs are queued for saving via BlobSavingQueue, which uses
6099
- * setTimeout(fn, 0) to completely isolate from Dexie's transaction context.
6100
- * Each blob is saved atomically using Table.update() with its keyPath to
6101
- * avoid race conditions with other property changes.
6249
+ * Resolved blobs are persisted via db.blobDownloadTracker.enqueueSave(),
6250
+ * which internally uses a queue that runs in a fresh JS task to completely
6251
+ * isolate from Dexie's transaction context. Each blob is saved atomically
6252
+ * using Table.update() with its keyPath to avoid race conditions with other
6253
+ * property changes.
6102
6254
  *
6103
6255
  * Blob downloads use Authorization header (same as sync) via the server
6104
6256
  * proxy endpoint: GET /blob/{ref}
@@ -6109,8 +6261,6 @@ function createBlobResolveMiddleware(db) {
6109
6261
  name: 'blobResolve',
6110
6262
  level: 2, // Run above cache (0) and other middlewares (1) to resolve BlobRefs from cached data
6111
6263
  create(downlevelDatabase) {
6112
- // Create a single queue instance for this database
6113
- const blobSavingQueue = new BlobSavingQueue(db);
6114
6264
  return Object.assign(Object.assign({}, downlevelDatabase), { table(tableName) {
6115
6265
  var _a;
6116
6266
  if (!db.cloud) {
@@ -6131,7 +6281,7 @@ function createBlobResolveMiddleware(db) {
6131
6281
  }
6132
6282
  return downlevelTable.get(req).then((result) => {
6133
6283
  if (result && hasUnresolvedBlobRefs(result)) {
6134
- return resolveAndSave(downlevelTable, req.trans, req.key, result, blobSavingQueue, db);
6284
+ return resolveAndSave(downlevelTable, req.trans, req.key, result, db);
6135
6285
  }
6136
6286
  return result;
6137
6287
  });
@@ -6148,7 +6298,7 @@ function createBlobResolveMiddleware(db) {
6148
6298
  return results;
6149
6299
  return Dexie.Promise.all(results.map((result, index) => {
6150
6300
  if (result && hasUnresolvedBlobRefs(result)) {
6151
- return resolveAndSave(downlevelTable, req.trans, req.keys[index], result, blobSavingQueue, db);
6301
+ return resolveAndSave(downlevelTable, req.trans, req.keys[index], result, db);
6152
6302
  }
6153
6303
  return result;
6154
6304
  }));
@@ -6168,7 +6318,7 @@ function createBlobResolveMiddleware(db) {
6168
6318
  return result;
6169
6319
  return Dexie.Promise.all(result.result.map((item) => {
6170
6320
  if (item && hasUnresolvedBlobRefs(item)) {
6171
- return resolveAndSave(downlevelTable, req.trans, undefined, item, blobSavingQueue, db);
6321
+ return resolveAndSave(downlevelTable, req.trans, undefined, item, db);
6172
6322
  }
6173
6323
  return item;
6174
6324
  })).then((resolved) => (Object.assign(Object.assign({}, result), { result: resolved })));
@@ -6186,7 +6336,7 @@ function createBlobResolveMiddleware(db) {
6186
6336
  return cursor; // No values requested, so no resolution needed
6187
6337
  if (!dbUrl)
6188
6338
  return cursor; // No database URL configured, can't resolve blobs
6189
- return createBlobResolvingCursor(cursor, downlevelTable, blobSavingQueue, db);
6339
+ return createBlobResolvingCursor(cursor, downlevelTable, db);
6190
6340
  });
6191
6341
  } });
6192
6342
  } });
@@ -6203,7 +6353,7 @@ function createBlobResolveMiddleware(db) {
6203
6353
  * Returns the cursor synchronously. Resolution happens in start() before
6204
6354
  * each onNext callback, ensuring cursor.value is always available.
6205
6355
  */
6206
- function createBlobResolvingCursor(cursor, table, blobSavingQueue, db) {
6356
+ function createBlobResolvingCursor(cursor, table, db) {
6207
6357
  // Create wrapped cursor using Object.create() - inherits everything.
6208
6358
  // Important: .key and .primaryKey must be explicitly overridden with
6209
6359
  // closure-based getters to prevent native IDBCursorWithValue getters from
@@ -6211,11 +6361,15 @@ function createBlobResolvingCursor(cursor, table, blobSavingQueue, db) {
6211
6361
  // throws "Illegal invocation" in Chrome 146+.
6212
6362
  const wrappedCursor = Object.create(cursor, {
6213
6363
  key: {
6214
- get() { return cursor.key; },
6364
+ get() {
6365
+ return cursor.key;
6366
+ },
6215
6367
  configurable: true,
6216
6368
  },
6217
6369
  primaryKey: {
6218
- get() { return cursor.primaryKey; },
6370
+ get() {
6371
+ return cursor.primaryKey;
6372
+ },
6219
6373
  configurable: true,
6220
6374
  },
6221
6375
  value: {
@@ -6233,7 +6387,7 @@ function createBlobResolvingCursor(cursor, table, blobSavingQueue, db) {
6233
6387
  onNext();
6234
6388
  return;
6235
6389
  }
6236
- resolveAndSave(table, cursor.trans, cursor.primaryKey, rawValue, blobSavingQueue, db, true).then((resolved) => {
6390
+ resolveAndSave(table, cursor.trans, cursor.primaryKey, rawValue, db, true).then((resolved) => {
6237
6391
  wrappedCursor.value = resolved;
6238
6392
  onNext();
6239
6393
  }, (err) => {
@@ -6261,7 +6415,7 @@ function createBlobResolvingCursor(cursor, table, blobSavingQueue, db) {
6261
6415
  * Returns Dexie.Promise to preserve PSD context.
6262
6416
  */
6263
6417
  function resolveAndSave(table, trans, pKey, // optional. If missing, tries to extract from object using primary key path
6264
- obj, blobSavingQueue, db, isCursorValue = false // Flag to indicate if we're resolving a cursor value (which may not have a primary key)
6418
+ obj, db, isCursorValue = false // Flag to indicate if we're resolving a cursor value (which may not have a primary key)
6265
6419
  ) {
6266
6420
  var _a;
6267
6421
  try {
@@ -6298,21 +6452,19 @@ obj, blobSavingQueue, db, isCursorValue = false // Flag to indicate if we're res
6298
6452
  ? Dexie.getByKeyPath(obj, primaryKey.keyPath)
6299
6453
  : undefined;
6300
6454
  if (key !== undefined) {
6301
- // Queue each resolved blob individually for atomic update
6302
- // This uses setTimeout(fn, 0) to completely isolate from
6303
- // Dexie's transaction context (avoids inheriting PSD)
6304
- if (isReadonly) {
6305
- blobSavingQueue.saveBlobs(table.name, key, resolvedBlobs);
6306
- }
6307
- else {
6308
- // For rw transactions, we can save directly without queueing
6309
- // since we're still in the same transaction context
6310
- table
6311
- .mutate({ type: 'put', keys: [key], values: [resolved], trans })
6312
- .catch((err) => {
6313
- console.error(`Failed to save resolved blob on ${table.name}:${key}:`, err);
6314
- });
6315
- }
6455
+ // Hand off persistence to the tracker. The tracker owns an
6456
+ // internal save-queue that runs in a fresh JS task (setTimeout 0)
6457
+ // completely outside any PSD context, so opening a Dexie rw
6458
+ // transaction there is always safe regardless of the calling
6459
+ // context. The tracker also keeps the in-flight download cache
6460
+ // alive until the save completes, so concurrent readers piggyback
6461
+ // on the already-downloaded data instead of refetching.
6462
+ db.blobDownloadTracker.enqueueSave(table.name, key, resolvedBlobs);
6463
+ }
6464
+ else if (resolvedBlobs.length > 0) {
6465
+ // No primary key — we can't persist. Release the in-flight cache
6466
+ // entries explicitly so they don't leak.
6467
+ db.blobDownloadTracker.releaseRefs(resolvedBlobs.map((b) => b.ref));
6316
6468
  }
6317
6469
  return resolved;
6318
6470
  })
@@ -8340,7 +8492,7 @@ function dexieCloud(dexie) {
8340
8492
  const downloading$ = createDownloadingState();
8341
8493
  dexie.cloud = {
8342
8494
  // @ts-ignore
8343
- version: "4.4.11",
8495
+ version: "4.4.13",
8344
8496
  options: Object.assign({}, DEFAULT_OPTIONS),
8345
8497
  schema: null,
8346
8498
  get currentUserId() {
@@ -8785,7 +8937,7 @@ function dexieCloud(dexie) {
8785
8937
  }
8786
8938
  }
8787
8939
  // @ts-ignore
8788
- dexieCloud.version = "4.4.11";
8940
+ dexieCloud.version = "4.4.13";
8789
8941
  Dexie.Cloud = dexieCloud;
8790
8942
 
8791
8943
  // In case the SW lives for a while, let it reuse already opened connections: