Skip to content

Commit 68ef80a

Browse files
authored
@uppy/golden-retriever: Store metaData in IndexedDB (#6362)
This PR fixes #6280, Adds **IndexedDB** as MetaDataStore falling back to `localStorage` when IndexedDB isn't available. AI Disclaimer : AI Used ### Changes - **`IndexedDBMetaDataStore.ts`** : IndexedDB-backed `MetaDataStore`; sync `get`/`set` via in-memory cache, async `load`/`save`. - **`IndexedDBStore.ts`** : add a `state` store to the existing `uppy-blobs` DB (v3→4, additive); expire it in `cleanup()`; close on `versionchange`. - **`index.ts`** : choose IndexedDB vs localStorage; restore is async and failure-safe. - **`MetaDataStore.ts`** : `setItem` wrapped in try/catch so the fallback can't throw either. - **Added Tests** ### Notes for reviewers - **Snapshot is `JSON.stringify`'d before `put()`** (not stored raw): structured clone *throws* on non-cloneable values; JSON drops them like localStorage did. Storing raw froze the snapshot and ghosted files. - **No migration** : an in-flight recovery at upgrade time starts fresh (rare, no data loss). - **Best-effort persistence** : write failures are swallowed; they must never break uploading. #### Manual Testing : Manually Tested with large Assemblies
2 parents 0b1f79c + 1baa84d commit 68ef80a

6 files changed

Lines changed: 310 additions & 22 deletions

File tree

.changeset/shy-knives-read.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@uppy/golden-retriever": minor
3+
---
4+
5+
Use IndexedDB as a metadata store and fall back to localStorage when IndexedDB is not available.
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import type { Body, Meta } from '@uppy/core'
2+
import throttle from 'lodash/throttle.js'
3+
import {
4+
connect,
5+
DB_NAME,
6+
METADATA_STORE_NAME,
7+
waitForRequest,
8+
} from './IndexedDBStore.js'
9+
import type { StoredState } from './MetaDataStore.js'
10+
11+
type MetaData<M extends Meta, B extends Body> = StoredState<M, B>['metadata']
12+
13+
// `metadata` is stored as a JSON string, not a live object. IndexedDB persists
14+
// values via the structured clone algorithm, which *throws* on anything
15+
// non-cloneable (e.g. a function) anywhere in the graph — whereas the
16+
// localStorage path used `JSON.stringify`, which silently drops such values.
17+
// Cloning the live Uppy/Transloadit state directly made `put` throw, the error
18+
// was swallowed, and the snapshot froze at an early state — so restored files
19+
// looked not-yet-uploaded and were ghosted. Serializing to JSON ourselves keeps
20+
// the exact semantics of the (working) localStorage path.
21+
type StateRecord = {
22+
id: string
23+
expires: number
24+
metadata: string
25+
}
26+
27+
type Options = { storeName: string; expires: number; throttleTime?: number }
28+
29+
/**
30+
* IndexedDB-backed twin of MetaDataStore. Same `load`/`get`/`set` contract, but
31+
* the recovery snapshot lives in IndexedDB instead of localStorage so large
32+
* Transloadit assemblies don't blow past localStorage's ~5MB quota (issue #6280).
33+
*
34+
* `get` stays synchronous (served from an in-memory cache) because it runs on
35+
* every state update; only loading and persisting touch IndexedDB.
36+
*/
37+
export default class IndexedDBMetaDataStore<M extends Meta, B extends Body> {
38+
#db: Promise<IDBDatabase>
39+
40+
#cache: MetaData<M, B> | null | undefined
41+
42+
#key: string
43+
44+
#expires: number
45+
46+
#saveThrottled: () => void
47+
48+
constructor(opts: Options) {
49+
this.#db = connect(DB_NAME)
50+
// Attach a handler now so a connection failure can't surface as an
51+
// unhandledrejection before load()/#save() get a chance to await it; both
52+
// of those handle the rejection themselves (persistence is best-effort).
53+
this.#db.catch(() => {})
54+
this.#key = opts.storeName
55+
this.#expires = opts.expires
56+
this.#saveThrottled =
57+
opts.throttleTime === 0
58+
? this.#save
59+
: throttle(this.#save, opts.throttleTime ?? 500, {
60+
leading: true,
61+
trailing: true,
62+
})
63+
}
64+
65+
async load(): Promise<MetaData<M, B> | undefined> {
66+
try {
67+
const db = await this.#db
68+
const record = await waitForRequest<StateRecord | undefined>(
69+
db
70+
.transaction([METADATA_STORE_NAME])
71+
.objectStore(METADATA_STORE_NAME)
72+
.get(this.#key),
73+
)
74+
if (!record || record.expires < Date.now()) return undefined
75+
this.#cache = JSON.parse(record.metadata)
76+
return this.#cache ?? undefined
77+
} catch {
78+
// Best-effort: a corrupt record, failed read, or unavailable DB must
79+
// never break restore — mirrors the localStorage path's tolerant parse.
80+
return undefined
81+
}
82+
}
83+
84+
get(): MetaData<M, B> | undefined {
85+
return this.#cache ?? undefined
86+
}
87+
88+
set(metadata: MetaData<M, B> | null): void {
89+
this.#cache = metadata
90+
this.#saveThrottled()
91+
}
92+
93+
#save = async (): Promise<void> => {
94+
try {
95+
const db = await this.#db
96+
const store = db
97+
.transaction([METADATA_STORE_NAME], 'readwrite')
98+
.objectStore(METADATA_STORE_NAME)
99+
if (this.#cache == null) {
100+
store.delete(this.#key)
101+
} else {
102+
// JSON.stringify (not raw structured clone) so non-cloneable values are
103+
// dropped instead of throwing. See the StateRecord comment above.
104+
store.put({
105+
id: this.#key,
106+
expires: Date.now() + this.#expires,
107+
metadata: JSON.stringify(this.#cache),
108+
})
109+
}
110+
} catch {
111+
// Persistence is best-effort; a failed write must never break uploading.
112+
}
113+
}
114+
}

packages/@uppy/golden-retriever/src/IndexedDBStore.ts

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ const indexedDB =
1414

1515
const isSupported = !!indexedDB
1616

17-
const DB_NAME = 'uppy-blobs'
17+
export const DB_NAME = 'uppy-blobs'
1818
const STORE_NAME = 'files' // maybe have a thumbnail store in the future
19+
20+
export const METADATA_STORE_NAME = 'metadata'
1921
const DEFAULT_EXPIRY = 24 * 60 * 60 * 1000 // 24 hours
20-
const DB_VERSION = 3
22+
const DB_VERSION = 4
2123
const MiB = 0x10_00_00
2224

2325
/**
@@ -36,11 +38,12 @@ function migrateExpiration(store: IDBObjectStore): void {
3638
}
3739
}
3840

39-
function connect(dbName: string): Promise<IDBDatabase> {
41+
export function connect(dbName: string): Promise<IDBDatabase> {
4042
const request = (indexedDB as IDBFactory).open(dbName, DB_VERSION)
4143
return new Promise((resolve, reject) => {
4244
request.onupgradeneeded = (event) => {
4345
const db: IDBDatabase = (event.target as IDBOpenDBRequest).result
46+
closeOnVersionChange(db)
4447
const transaction = (event.currentTarget as IDBOpenDBRequest)
4548
.transaction as IDBTransaction
4649

@@ -58,18 +61,38 @@ function connect(dbName: string): Promise<IDBDatabase> {
5861
migrateExpiration(store)
5962
}
6063

64+
if (event.oldVersion < 4) {
65+
// Added in v4: a store for GoldenRetriever's recovery snapshot, which
66+
// moved out of localStorage to escape its ~5MB quota. See issue #6280.
67+
const store = db.createObjectStore(METADATA_STORE_NAME, {
68+
keyPath: 'id',
69+
})
70+
store.createIndex('expires', 'expires', { unique: false })
71+
}
72+
6173
transaction.oncomplete = () => {
6274
resolve(db)
6375
}
6476
}
6577
request.onsuccess = (event) => {
66-
resolve((event.target as IDBRequest).result)
78+
const db = (event.target as IDBRequest).result as IDBDatabase
79+
closeOnVersionChange(db)
80+
resolve(db)
6781
}
6882
request.onerror = reject
6983
})
7084
}
7185

72-
function waitForRequest<T>(request: IDBRequest): Promise<T> {
86+
/**
87+
* Close this connection when another tab/instance requests a higher DB version,
88+
* so we never block its upgrade (and it never blocks ours). The connection is
89+
* unusable afterwards, but recovery persistence is best-effort.
90+
*/
91+
function closeOnVersionChange(db: IDBDatabase): void {
92+
db.onversionchange = () => db.close()
93+
}
94+
95+
export function waitForRequest<T>(request: IDBRequest): Promise<T> {
7396
return new Promise((resolve, reject) => {
7497
request.onsuccess = (event) => {
7598
resolve((event.target as IDBRequest).result)
@@ -230,17 +253,13 @@ class IndexedDBStore {
230253
}
231254

232255
/**
233-
* Delete all stored blobs that have an expiry date that is before Date.now().
234-
* This is a static method because it deletes expired blobs from _all_ Uppy instances.
256+
* Delete every expired entry in a store, using its `expires` index.
235257
*/
236-
static async cleanup(): Promise<void> {
237-
const db = await connect(DB_NAME)
238-
const transaction = db.transaction([STORE_NAME], 'readwrite')
239-
const store = transaction.objectStore(STORE_NAME)
258+
static #deleteExpired(store: IDBObjectStore): Promise<void> {
240259
const request = store
241260
.index('expires')
242261
.openCursor(IDBKeyRange.upperBound(Date.now()))
243-
await new Promise<void>((resolve, reject) => {
262+
return new Promise<void>((resolve, reject) => {
244263
request.onsuccess = (event) => {
245264
const cursor = (event.target as IDBRequest).result
246265
if (cursor) {
@@ -252,7 +271,31 @@ class IndexedDBStore {
252271
}
253272
request.onerror = reject
254273
})
255-
db.close()
274+
}
275+
276+
/**
277+
* Delete all stored blobs and recovery snapshots that have an expiry date
278+
* before Date.now(). This is a static method because it deletes expired data
279+
* from _all_ Uppy instances.
280+
*/
281+
static async cleanup(): Promise<void> {
282+
const db = await connect(DB_NAME)
283+
try {
284+
const transaction = db.transaction(
285+
[STORE_NAME, METADATA_STORE_NAME],
286+
'readwrite',
287+
)
288+
await Promise.all([
289+
IndexedDBStore.#deleteExpired(transaction.objectStore(STORE_NAME)),
290+
IndexedDBStore.#deleteExpired(
291+
transaction.objectStore(METADATA_STORE_NAME),
292+
),
293+
])
294+
} finally {
295+
// Always release the connection, even if expiry fails — otherwise a
296+
// rejected delete would leak the connection (and could block upgrades).
297+
db.close()
298+
}
256299
}
257300
}
258301

packages/@uppy/golden-retriever/src/MetaDataStore.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,11 @@ export default class MetaDataStore<M extends Meta, B extends Body> {
115115
localStorage.removeItem(this.name)
116116
return
117117
}
118-
const state = JSON.stringify(this.#state)
119-
localStorage.setItem(this.name, state)
118+
try {
119+
localStorage.setItem(this.name, JSON.stringify(this.#state))
120+
} catch {
121+
// Best-effort: out of quota / disabled storage must not break uploading.
122+
}
120123
}
121124

122125
/**

0 commit comments

Comments
 (0)