All files / src/state idb.ts

63.47% Statements 73/115
84.61% Branches 22/26
92.3% Functions 12/13
63.47% Lines 73/115

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262                                                                                                      2x                     2x                     2x 2x 2x 2x 2x                       2x     2x     2x     2x   84x 84x 84x                 430x 430x 84x 84x 84x 84x                                                                                                     430x 430x 430x 430x 430x 430x 430x                 430x     295x 295x 295x               2x 426x 426x 125x 125x 125x 125x 125x   426x       80x 80x 80x   426x 90x 90x 90x   426x 34x 34x 34x 34x 34x 34x 34x 34x   426x 101x 101x 3x 3x 101x 101x 426x 426x                 5x 5x 5x                   2x 145x 145x 145x 145x  
/**
 * One IndexedDB database, behind a key-value interface that cannot fail.
 *
 * The app has managed until now on `localStorage` and `sessionStorage` alone,
 * and the reason to reach past them is narrow: **the two things this database
 * holds cannot be JSON.** A `FileSystemFileHandle` is a capability object —
 * structured-cloneable, and meaningless as text — and a durable workspace is a
 * few hundred kilobytes of session configs, which is over the practical ceiling
 * of a `localStorage` value on some browsers and would be a synchronous parse on
 * the boot path in all of them. Neither is a reason to move anything that is
 * already happy in `UiMemory`, and nothing is moved.
 *
 * ## Why a wrapper, and not `idb-keyval`
 *
 * This package has three runtime dependencies. A hundred lines that do one
 * `open`, one `transaction` and one request-to-promise hop is smaller than the
 * lockfile churn of a fourth, and it is the layer where the two failures below
 * have to be handled anyway.
 *
 * ## The two failures, which are the whole point of this file
 *
 * **A browser that refuses storage still runs.** `UiMemory` (`state/UiMemory.ts`)
 * already established this: Safari's private mode and a strict embedding both
 * throw on the first write, and losing a filter must never break a view. The
 * fallback here is the same shape — a `Map` — so no caller has a "is there
 * storage?" branch, and every caller is written once.
 *
 * **`indexedDB.open()` can answer with neither `success` nor `error`.** In a
 * private window, or where another tab holds an older version open, it fires
 * `blocked`, or nothing at all. A bare `await` on it therefore hangs *forever*,
 * and because the workspace is read on the boot path, the symptom would be a
 * blank page with nothing in the console — the worst failure this app has, for
 * the least interesting reason. So the open races a timer, and losing the race
 * is not an error: it is the memory store, permanently, for this page load.
 *
 * ## One database, every store declared at once
 *
 * Object stores can only be created inside an `upgradeneeded`, so adding one
 * later costs a version bump and a migration. Both names are declared here from
 * the start — the cost of a store nobody writes to is nothing.
 */
 
/** What both callers see, whichever implementation is underneath. */
export interface KeyValueStore {
  get<T>(key: string): Promise<T | null>
  set(key: string, value: unknown): Promise<void>
  delete(key: string): Promise<void>
  keys(): Promise<string[]>
  clear(): Promise<void>
}
 
const DB_NAME = 'chromosomix'
/**
 * Bumped to 2 for `directories`.
 *
 * The header below says every store is declared at once so that adding one
 * later costs a version bump and a migration, and this is that bump — the
 * migration being none, because `onupgradeneeded` creates whatever is missing
 * and an existing database keeps both of its stores untouched. A reader who
 * downgrades to a build that asks for version 1 gets `null` from `database()`
 * and the memory fallback, which is the state a private window is already in.
 */
const DB_VERSION = 2
 
/**
 * The stores this database holds.
 *
 * `workspace` is one row under one key; `handles` is one row per file;
 * `directories` is one row per connected folder. They are separate stores rather
 * than one because "forget the files I have granted", "forget the folders I
 * connected" and "forget which tabs were open" are three things a reader may
 * reasonably want separately, and a store is the unit `clear()` acts on.
 */
export const STORES = {
  workspace: 'workspace',
  handles: 'handles',
  directories: 'directories',
} as const
 
export type StoreName = (typeof STORES)[keyof typeof STORES]
 
/**
 * How long to wait for `open` before giving up on it.
 *
 * Generous, because a cold profile on a slow disk is legitimately not instant,
 * and bounded, because the boot path waits on this. Two seconds of a blank page
 * is a bad morning; an unbounded wait is a bug report that says "it never
 * loads" with no console output to go with it.
 */
const OPEN_TIMEOUT_MS = 2000
 
/** Resolved once per page load: the database, or `null` for "use memory". */
let opening: Promise<IDBDatabase | null> | null = null
 
/** The fallback, and the test double. Keyed `<store><key>`. */
const fallback = new Map<string, unknown>()
 
/** Set by {@link useMemoryStores}; skips the open entirely. */
let forceMemory = false
 
function supported(): boolean {
  return !forceMemory && typeof indexedDB !== 'undefined'
}
 
/**
 * The database, opened at most once.
 *
 * Every rejection path resolves to `null` rather than throwing, because there is
 * exactly one thing a caller can do about a database that will not open and this
 * function has already done it.
 */
function database(): Promise<IDBDatabase | null> {
  if (opening !== null) return opening
  if (!supported()) {
    opening = Promise.resolve(null)
    return opening
  }
  opening = new Promise<IDBDatabase | null>((resolve) => {
    let settled = false
    const finish = (value: IDBDatabase | null): void => {
      if (settled) return
      settled = true
      resolve(value)
    }
    // The timer is the whole reason this is not one `await`. See the header:
    // `blocked` is a real answer and it is not one of the two events below.
    const timer = setTimeout(() => finish(null), OPEN_TIMEOUT_MS)
    const done = (value: IDBDatabase | null): void => {
      clearTimeout(timer)
      finish(value)
    }
    let request: IDBOpenDBRequest
    try {
      request = indexedDB.open(DB_NAME, DB_VERSION)
    } catch {
      // Throwing synchronously is what a hard-blocked embedding does.
      done(null)
      return
    }
    request.onupgradeneeded = () => {
      const db = request.result
      for (const name of Object.values(STORES)) {
        if (!db.objectStoreNames.contains(name)) db.createObjectStore(name)
      }
    }
    request.onsuccess = () => done(request.result)
    request.onerror = () => done(null)
    request.onblocked = () => done(null)
  })
  return opening
}
 
/** One request, as a promise. Rejections become `null`, never a throw. */
function ask<T>(request: IDBRequest<T>): Promise<T | null> {
  return new Promise<T | null>((resolve) => {
    request.onsuccess = () => resolve(request.result)
    request.onerror = () => resolve(null)
  })
}
 
/**
 * Run `body` against one object store, or answer `null` if there is no database.
 *
 * The transaction is created per call rather than held, because IndexedDB
 * auto-closes an idle one and a held transaction is the classic way to get
 * `TransactionInactiveError` from a line that looks synchronous.
 */
async function within<T>(
  name: StoreName,
  mode: IDBTransactionMode,
  body: (store: IDBObjectStore) => Promise<T | null>,
): Promise<T | null> {
  const db = await database()
  if (db === null) return null
  try {
    return await body(db.transaction(name, mode).objectStore(name))
  } catch {
    // A store that vanished under a downgraded build, a quota refusal mid-write,
    // a database deleted from devtools while the page is open. Every one of them
    // is "storage did not work", which is the state the memory fallback covers.
    return null
  }
}
 
/** `<store><key>` — a separator no store name or key contains. */
function slot(name: StoreName, key: string): string {
  return `${name}${key}`
}
 
/**
 * A store by name.
 *
 * Cheap to call: nothing is opened until the first read or write, so a page that
 * never restores anything never touches IndexedDB at all.
 */
export function store(name: StoreName): KeyValueStore {
  return {
    async get<T>(key: string): Promise<T | null> {
      const found = await within(name, 'readonly', (s) => ask<unknown>(s.get(key)))
      if (found !== null && found !== undefined) return found as T
      const held = fallback.get(slot(name, key))
      return held === undefined ? null : (held as T)
    },
 
    async set(key: string, value: unknown): Promise<void> {
      // Written to both, always. The memory map is not only the fallback: it is
      // also what makes a read *this page load* correct when the database is
      // present but slow, and it costs a reference.
      fallback.set(slot(name, key), value)
      await within(name, 'readwrite', (s) => ask(s.put(value, key)))
    },
 
    async delete(key: string): Promise<void> {
      fallback.delete(slot(name, key))
      await within(name, 'readwrite', (s) => ask(s.delete(key)))
    },
 
    async keys(): Promise<string[]> {
      const stored = await within(name, 'readonly', (s) => ask<IDBValidKey[]>(s.getAllKeys()))
      const names = new Set<string>((stored ?? []).map((key) => String(key)))
      const prefix = `${name}`
      for (const held of fallback.keys()) {
        if (held.startsWith(prefix)) names.add(held.slice(prefix.length))
      }
      return [...names]
    },
 
    async clear(): Promise<void> {
      const prefix = `${name}`
      for (const held of [...fallback.keys()]) {
        if (held.startsWith(prefix)) fallback.delete(held)
      }
      await within(name, 'readwrite', (s) => ask(s.clear()))
    },
  }
}
 
/**
 * Forget every store, including the fallback.
 *
 * What the "Forget everything" control calls for this half of the app's memory.
 * Deliberately not `deleteDatabase`: that needs every other tab to close its
 * connection first, and blocks silently until they do.
 */
export async function clearAll(): Promise<void> {
  for (const name of Object.values(STORES)) await store(name).clear()
}
 
/**
 * Make every store an in-memory one. **Tests only.**
 *
 * The node test runtime has no `indexedDB`, so most tests get the fallback for
 * free; this is for the ones that run under jsdom, where a half-implemented
 * global would otherwise be reached for. Also resets the open, so a test that
 * ran before a `useMemoryStores()` cannot leak its database into one after.
 */
export function useMemoryStores(): void {
  forceMemory = true
  opening = null
  fallback.clear()
}