Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 24 additions & 39 deletions addons/dexie-cloud/src/middlewares/blobResolveMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@
* Uses Dexie.waitFor() only for explicit rw transactions to keep them alive.
* For readonly or implicit transactions, resolves directly (no waitFor needed).
*
* Resolved blobs are queued for saving via BlobSavingQueue, which uses
* setTimeout(fn, 0) to completely isolate from Dexie's transaction context.
* Each blob is saved atomically using Table.update() with its keyPath to
* avoid race conditions with other property changes.
* Resolved blobs are persisted via db.blobDownloadTracker.enqueueSave(),
* which internally uses a queue that runs in a fresh JS task to completely
* isolate from Dexie's transaction context. Each blob is saved atomically
* using Table.update() with its keyPath to avoid race conditions with other
* property changes.
*
* Blob downloads use Authorization header (same as sync) via the server
* proxy endpoint: GET /blob/{ref}
Expand All @@ -34,7 +35,6 @@ import {
resolveAllBlobRefs,
ResolvedBlob,
} from '../sync/blobResolve';
import { BlobSavingQueue } from '../sync/BlobSavingQueue';
import { TXExpandos } from '../types/TXExpandos';
import { UserLogin } from '../dexie-cloud-client';

Expand All @@ -46,9 +46,6 @@ export function createBlobResolveMiddleware(
name: 'blobResolve',
level: 2, // Run above cache (0) and other middlewares (1) to resolve BlobRefs from cached data
create(downlevelDatabase: DBCore): DBCore {
// Create a single queue instance for this database
const blobSavingQueue = new BlobSavingQueue(db);

return {
...downlevelDatabase,
table(tableName: string): DBCoreTable {
Expand Down Expand Up @@ -82,7 +79,6 @@ export function createBlobResolveMiddleware(
req.trans,
req.key,
result,
blobSavingQueue,
db
);
}
Expand Down Expand Up @@ -112,7 +108,6 @@ export function createBlobResolveMiddleware(
req.trans,
req.keys[index],
result,
blobSavingQueue,
db
);
}
Expand Down Expand Up @@ -147,7 +142,6 @@ export function createBlobResolveMiddleware(
req.trans,
undefined,
item,
blobSavingQueue,
db
);
}
Expand All @@ -168,12 +162,7 @@ export function createBlobResolveMiddleware(
if (!cursor) return cursor; // No results, so no resolution needed
if (!req.values) return cursor; // No values requested, so no resolution needed
if (!dbUrl) return cursor; // No database URL configured, can't resolve blobs
return createBlobResolvingCursor(
cursor,
downlevelTable,
blobSavingQueue,
db
);
return createBlobResolvingCursor(cursor, downlevelTable, db);
});
},
};
Expand All @@ -196,7 +185,6 @@ export function createBlobResolveMiddleware(
function createBlobResolvingCursor(
cursor: DBCoreCursor,
table: DBCoreTable,
blobSavingQueue: BlobSavingQueue,
db: DexieCloudDB
): DBCoreCursor {
// Create wrapped cursor using Object.create() - inherits everything.
Expand All @@ -206,11 +194,15 @@ function createBlobResolvingCursor(
// throws "Illegal invocation" in Chrome 146+.
const wrappedCursor = Object.create(cursor, {
key: {
get() { return cursor.key; },
get() {
return cursor.key;
},
configurable: true,
},
primaryKey: {
get() { return cursor.primaryKey; },
get() {
return cursor.primaryKey;
},
configurable: true,
},
value: {
Expand All @@ -233,7 +225,6 @@ function createBlobResolvingCursor(
cursor.trans,
cursor.primaryKey,
rawValue,
blobSavingQueue,
db,
true
).then(
Expand Down Expand Up @@ -276,7 +267,6 @@ function resolveAndSave(
trans: DBCoreTransaction,
pKey: any | undefined, // optional. If missing, tries to extract from object using primary key path
obj: any,
blobSavingQueue: BlobSavingQueue,
db: DexieCloudDB,
isCursorValue: boolean = false // Flag to indicate if we're resolving a cursor value (which may not have a primary key)
): Promise<any> {
Expand Down Expand Up @@ -328,23 +318,18 @@ function resolveAndSave(
: undefined;

if (key !== undefined) {
// Queue each resolved blob individually for atomic update
// This uses setTimeout(fn, 0) to completely isolate from
// Dexie's transaction context (avoids inheriting PSD)
if (isReadonly) {
blobSavingQueue.saveBlobs(table.name, key, resolvedBlobs);
} else {
// For rw transactions, we can save directly without queueing
// since we're still in the same transaction context
table
.mutate({ type: 'put', keys: [key], values: [resolved], trans })
.catch((err) => {
console.error(
`Failed to save resolved blob on ${table.name}:${key}:`,
err
);
});
}
// Hand off persistence to the tracker. The tracker owns an
// internal save-queue that runs in a fresh JS task (setTimeout 0)
// — completely outside any PSD context, so opening a Dexie rw
// transaction there is always safe regardless of the calling
// context. The tracker also keeps the in-flight download cache
// alive until the save completes, so concurrent readers piggyback
// on the already-downloaded data instead of refetching.
db.blobDownloadTracker.enqueueSave(table.name, key, resolvedBlobs);
} else if (resolvedBlobs.length > 0) {
// No primary key — we can't persist. Release the in-flight cache
// entries explicitly so they don't leak.
db.blobDownloadTracker.releaseRefs(resolvedBlobs.map((b) => b.ref));
}

return resolved;
Expand Down
205 changes: 160 additions & 45 deletions addons/dexie-cloud/src/sync/BlobDownloadTracker.ts
Original file line number Diff line number Diff line change
@@ -1,78 +1,193 @@
import type { DexieCloudDB } from '../db/DexieCloudDB';
import { BlobRef } from './blobResolve';
import { BlobRef, ResolvedBlob } from './blobResolve';
import { BlobSavingQueue } from './BlobSavingQueue';
import { loadCachedAccessToken } from './loadCachedAccessToken';

/**
* Deduplicates in-flight blob downloads.
* Owns the full lifecycle of downloaded blobs:
* 1. Deduplicates concurrent downloads for the same ref.
* 2. Bounds the number of concurrent network fetches (MAX_CONCURRENT)
* so that ad-hoc reads can't starve the HTTP connection pool. Calls
* beyond the cap queue in FIFO order as slots free. The slot is held
* only for the duration of the fetch — NOT until persistence — to
* avoid deadlocks when a single object contains more blob refs than
* MAX_CONCURRENT (a sequential resolver would otherwise hold every
* slot itself while waiting for the next).
* 3. Keeps the in-flight promise alive after the network fetch completes,
* until the blob has been persisted back to IndexedDB. This way,
* readers that ask for the same ref while it is queued for saving
* can piggyback on the existing promise instead of refetching.
* In-flight membership and slot ownership are independent: a piggyback
* reader consumes neither a slot nor extra memory beyond the existing
* cached Uint8Array.
* 4. Persists resolved blobs via an internal BlobSavingQueue, and
* releases the in-flight entry when persistence completes.
*
* Both the blob-resolve middleware and the eager blob downloader may
* try to fetch the same blob concurrently. This tracker ensures each
* unique blob ref is only downloaded once — subsequent requests for
* the same ref piggyback on the existing promise.
* Both the blob-resolve middleware and the eager blob downloader use this
* tracker. Instantiate once per DexieCloudDB.
*/

/**
* Maximum number of concurrent blob fetches.
*
* Instantiate once per DexieCloudDB.
* Historically 6 to match the HTTP/1.1 same-origin connection cap that
* browsers enforce. With HTTP/2 (the typical transport for Dexie Cloud
* today) many streams multiplex over a single TCP connection, so the
* old cap is overly conservative. 10 is a modest bump that still keeps
* memory pressure (in-flight Uint8Arrays) and server load bounded.
* Can be made configurable via DexieCloudOptions if a real need arises.
*/
export const MAX_CONCURRENT = 10;

export class BlobDownloadTracker {
private inFlight = new Map<string, Promise<Uint8Array>>();
private db: DexieCloudDB;
private savingQueue: BlobSavingQueue;
private activeFetches = 0;
private waiting: Array<() => void> = [];

constructor(db: DexieCloudDB) {
this.db = db;
this.savingQueue = new BlobSavingQueue(db, (refs) => {
// Called by the queue when a save transaction has completed
// (regardless of success). Drop the in-flight cache entries now —
// any future reader will go through IndexedDB instead.
for (const ref of refs) {
this.inFlight.delete(ref);
}
});
}

/**
* Download a blob, deduplicating concurrent requests for the same ref.
* Download a blob, deduplicating concurrent requests for the same ref
* and respecting the global fetch concurrency cap.
*
* Lifecycle:
* - Slot is acquired before the fetch and released as soon as the
* fetch settles (success or failure).
* - The in-flight entry survives a successful fetch and lives on
* until persistence completes (via enqueueSave) or releaseRefs
* is called. On fetch failure, the entry is removed immediately
* so a future call can retry.
*
* @param blobRef - The BlobRef to download
* @param dbUrl - Base URL for the database (e.g., 'https://mydb.dexie.cloud')
*/
download(blobRef: BlobRef, dbUrl: string): Promise<Uint8Array> {
let promise = this.inFlight.get(blobRef.ref);
if (!promise) {
promise = loadCachedAccessToken(this.db)
.then((accessToken) => {
// accessToken may be null for anonymous/unauthenticated users.
// Public realm blobs (rlm-public) are accessible without auth.
// downloadBlob will omit the Authorization header when token is null.
return downloadBlob(blobRef, dbUrl, accessToken);
})
.finally(() => this.inFlight.delete(blobRef.ref));
// When the promise settles (either fulfilled or rejected), remove it from the in-flight map
promise = this.acquireSlot()
.then(() =>
this.downloadBlob(blobRef, dbUrl).finally(() => this.releaseSlot())
)
.catch((err) => {
// On error, remove immediately so a future call can retry.
// (Slot already released by the .finally above.)
this.inFlight.delete(blobRef.ref);
throw err;
});
this.inFlight.set(blobRef.ref, promise);
}
return promise;
}
}
/**
* Download blob data from server via proxy endpoint.
* Uses auth header for authentication (same as sync).
* When accessToken is null, the request is made without Authorization header —
* this allows downloading blobs from public realms (rlm-public) for
* unauthenticated users.
*
* @param blobRef - The BlobRef to download
* @param dbUrl - Base URL for the database (e.g., 'https://mydb.dexie.cloud')
* @param accessToken - Access token for authentication, or null for anonymous access
*/

export async function downloadBlob(
blobRef: BlobRef,
dbUrl: string,
accessToken: string | null
): Promise<Uint8Array> {
const downloadUrl = `${dbUrl}/blob/${blobRef.ref}`;
const headers: HeadersInit = {};
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
/**
* Queue resolved blobs for persisting back to IndexedDB.
* When the save transaction completes, the corresponding in-flight
* entries are released.
*/
enqueueSave(
tableName: string,
primaryKey: any,
resolvedBlobs: ResolvedBlob[]
): void {
this.savingQueue.saveBlobs(tableName, primaryKey, resolvedBlobs);
}
const response = await fetch(downloadUrl, { headers });

if (!response.ok) {
throw new Error(
`Failed to download blob ${blobRef.ref}: ${response.status} ${response.statusText}`
);
/**
* Wait until all previously enqueued saves have been persisted to
* IndexedDB. Used by callers that need to make decisions based on
* on-disk state — e.g., the eager downloader looping over rows with
* `_hasBlobRefs=1` in chunks, where each iteration must see the
* previous chunk's writes before re-querying.
*
* New saves enqueued AFTER drainPendingSaves() is called do NOT extend
* the wait.
*/
drainPendingSaves(): Promise<void> {
return this.savingQueue.drain();
}

const arrayBuffer = await response.arrayBuffer();
return new Uint8Array(arrayBuffer);
/**
* Release in-flight entries without going through the internal saving
* queue. Used when the caller persists the blobs itself, or when no
* primary key was available and the data won't be persisted at all.
*/
releaseRefs(refs: string[]): void {
for (const ref of refs) {
this.inFlight.delete(ref);
}
}

private acquireSlot(): Promise<void> {
if (this.activeFetches < MAX_CONCURRENT) {
this.activeFetches++;
return Promise.resolve();
}
return new Promise<void>((resolve) => {
this.waiting.push(() => {
this.activeFetches++;
resolve();
});
});
}

private releaseSlot(): void {
this.activeFetches--;
const next = this.waiting.shift();
if (next) next();
}

/**
* Download blob data from server via proxy endpoint.
* Uses auth header for authentication (same as sync).
* When accessToken is null, the request is made without Authorization header —
* this allows downloading blobs from public realms (rlm-public) for
* unauthenticated users.
*
* @param blobRef - The BlobRef to download
* @param dbUrl - Base URL for the database (e.g., 'https://mydb.dexie.cloud')
*/

private async downloadBlob(
blobRef: BlobRef,
dbUrl: string
): Promise<Uint8Array> {
const accessToken = await loadCachedAccessToken(this.db);
const downloadUrl = `${dbUrl}/blob/${blobRef.ref}`;
const headers: HeadersInit = {};
if (accessToken) {
// accessToken may be null for anonymous/unauthenticated users.
// Public realm blobs (rlm-public) are accessible without auth.
// downloadBlob will omit the Authorization header when token is null.
headers['Authorization'] = `Bearer ${accessToken}`;
}
// cache: 'no-store' prevents the browser from storing this response in its
// HTTP cache. The server sets a long Expires/Cache-Control header on blob
// responses (blobs are immutable and content-addressed), which would
// otherwise cause the browser to keep a copy in its disk cache in addition
// to the copy we persist to IndexedDB — doubling storage for every blob.
// Since we always persist to IndexedDB and subsequent reads go through
// IndexedDB (never re-fetch), the browser cache copy is pure overhead.
const response = await fetch(downloadUrl, { headers, cache: 'no-store' });

if (!response.ok) {
throw new Error(
`Failed to download blob ${blobRef.ref}: ${response.status} ${response.statusText}`
);
}

const arrayBuffer = await response.arrayBuffer();
return new Uint8Array(arrayBuffer);
}
}
Loading
Loading