All files / src/h5 Hdf5File.ts

66.87% Statements 214/320
70.66% Branches 53/75
82.75% Functions 24/29
66.87% Lines 214/320

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 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429                            2x   2x 2x               2x   2x                       676x 676x 676x 676x 676x 676x 676x   676x 676x 676x 676x                 676x 676x 676x 293x 293x     1x 1x 1x 1x 1x 1x 1x 293x             293x 676x 1x 1x 1x 1x               675x 675x 675x 676x         1120x 307x 307x 307x 307x 307x 307x 676x       675x 675x   2x 2x 833x 833x 833x   833x 833x   833x 833x 833x   833x   2x 833x 833x 833x 833x 833x 833x 833x 833x 833x 833x 833x 833x 833x 833x 833x 833x 833x 833x 833x   2x 753x 753x   2x 800x 800x 800x           800x 800x 13112x 13112x 800x 800x   2x 4442x     4442x 4442x   2x 236x 236x 70x 236x   2x       473x 473x 473x 473x   2x 664x 664x 664x   2x 657x 657x           657x 657x   2x 4494x 4494x 4494x   2x 1400x 1400x 1400x     2x 5894x 5894x 5894x 5894x 5894x 5894x           5894x 5894x 5894x   2x                         2x 1091x 1091x           1091x 1091x 1091x   2x 158x   158x 158x 158x 158x 158x 158x 158x 158x 158x 158x 158x 158x   2x     672x 672x 672x 672x 672x 672x 672x 672x             2x 15506x 15506x 15506x 15506x   2x       2x 9342x 9342x 9342x 8875x 9342x   2x 7215x 7215x           7215x           7215x 7215x     2x 5894x 5894x             5894x 5894x 5894x 5894x 5894x                     2x 13112x 13112x 13112x 13112x 13112x 13112x       13112x 13112x   2x                                                         2x 13784x     13784x 2x             15410x 15410x 15410x 15410x     13112x 13112x 13112x 13112x 13112x 13112x                    
/**
 * The concrete {@link Hdf5Handle} over a {@link ByteSource}.
 *
 * A handle is *rooted at a group*, so a plain `.metacool` (`/`) and one entry
 * of an `.mmetacool` (`/entries/rep1`) are the same object with a different
 * prefix and no call site branches on which it is (spec §3.4, §3.5). Every
 * path a caller passes is resolved inside that root — a leading `/` is stripped
 * rather than escaping to the file root, so `has('/chroms')` on an mmetacool
 * entry asks about *that entry's* `chroms` and can never accidentally answer
 * about the collection.
 */
 
import type { AttrValue, Hdf5Handle } from './api'
import type { ByteSource, Table } from '../types'
import { MetaCoolerFormatError, ViewerError, truncateNames } from '../errors'
import type { H5Module, H5wasmDataset, H5wasmFile, Metadata } from './bootstrap'
import { getH5 } from './bootstrap'
import {
  classify,
  readInt32Slice,
  readNumericSlice,
  readStringSlice,
  readTableGroup,
  type HeapAccess,
} from './readTable'
import { orderEntries } from '../metacooler/detect'
 
const UTF8 = new TextDecoder('utf-8')
 
/**
 * Open an HDF5 file through a byte source and root it at a group.
 *
 * @param uri The `file` or `file::group` URI this handle represents. Carried
 *   only for error messages — the bytes come from `source`, the group from
 *   `group`, both already resolved by `parseUri`.
 * @param source Mounted on first call; the handle takes ownership and closes
 *   it in {@link Hdf5Handle.close}.
 * @param group HDF5 group path, `'/'` for a plain file.
 */
export async function openHdf5(
  uri: string,
  source: ByteSource,
  group: string,
): Promise<Hdf5Handle> {
  const h5 = await getH5()
  const mounted = await source.mount()
 
  let file: H5wasmFile
  try {
    file = new h5.File(mounted, 'r')
  } catch (cause) {
    source.close()
    throw new ViewerError(
      'not-hdf5',
      `${uri} could not be opened as an HDF5 file: ${describe(cause)}`,
      { uri, source: source.id },
    )
  }
 
  const root = normalise(group)
  try {
    if (root !== '/') {
      const node = file.get(root)
      if (node === null) {
        // Mirrors `schema.validate`'s message for a URI naming a group that is
        // not there, so a typo reads the same in the browser and the CLI.
        throw new ViewerError(
          'entry-not-found',
          `Group '${root}' not found in ${source.id}. ` +
            `Top-level keys: ${truncateNames(file.keys())}`,
          { uri, group: root, available: file.keys() },
        )
      }
      if (!(node instanceof h5.Group)) {
        throw new MetaCoolerFormatError(
          `'${root}' in ${source.id} is not an HDF5 group`,
          { uri, group: root },
          'entry-not-found',
        )
      }
    }
  } catch (err) {
    file.close()
    source.close()
    throw err
  }
 
  // Captured while the root is in hand; see `Hdf5Handle.rootEntries`.
  //
  // Two group names, one idea: an mmetacool keeps its maps under `/entries`
  // and an mcool keeps its resolutions under `/resolutions`. Both are ladders
  // of sibling coolers, and the picker that offers them does not care which
  // word the format chose.
  let rootEntries: string[] = []
  let rootEntryGroup = ''
  try {
    for (const group of ['entries', 'resolutions']) {
      // `get_type`, not `keys().includes(...)`: a listing of the root walks
      // every link in it, and a single-cell container has one per cell —
      // several thousand records scattered across the whole file (§12). Asking
      // whether *one* name exists is a lookup, and it is the same answer.
      if (file.get_type(`/${group}`) < 0) continue
      const node = file.get(group)
      if (!(node instanceof h5.Group)) continue
      rootEntries = orderEntries(node.keys())
      rootEntryGroup = group
      break
    }
  } catch {
    // A file with no readable entry group simply has no entries to pick.
  }
 
  return new Hdf5File(uri, root, h5, file, source, rootEntries, rootEntryGroup)
}
 
class Hdf5File implements Hdf5Handle {
  readonly uri: string
  readonly group: string
  readonly rootEntries: string[]
  readonly rootEntryGroup: string
 
  private readonly h5: H5Module
  private readonly file: H5wasmFile
  /** What a numeric column is read straight out of the wasm heap with (`readTable.heapSlice`). */
  private readonly heap: HeapAccess
  private readonly source: ByteSource
  private closed = false
  /** A view from {@link at}, which shares the mount and must not close it. */
  private readonly borrowed: boolean
 
  constructor(
    uri: string,
    group: string,
    h5: H5Module,
    file: H5wasmFile,
    source: ByteSource,
    rootEntries: string[] = [],
    rootEntryGroup = '',
    borrowed = false,
  ) {
    this.uri = uri
    this.group = group
    this.rootEntries = rootEntries
    this.rootEntryGroup = rootEntryGroup
    this.borrowed = borrowed
    this.h5 = h5
    this.file = file
    this.heap = { h5, file }
    this.source = source
  }
 
  attrs(): Record<string, AttrValue> {
    return this.attrsAt('')
  }
 
  attrsAt(path: string): Record<string, AttrValue> {
    const entity = this.entity(path)
    const hasAttrs = entity instanceof this.h5.Group || entity instanceof this.h5.Dataset
    if (entity === null || !hasAttrs) {
      throw new MetaCoolerFormatError(
        `${this.describe(path)} does not exist, or carries no attributes`,
        { uri: this.uri, path: this.resolve(path) },
      )
    }
    const out: Record<string, AttrValue> = {}
    for (const [name, attr] of Object.entries(entity.attrs)) {
      out[name] = this.decodeAttr(entity.path, name, attr.metadata, attr.json_value)
    }
    return out
  }
 
  has(path: string): boolean {
    this.assertOpen()
    // `get_type` returns -1 for a missing path and does so silently; `get()`
    // would build a wrapper object for the hit case that is then thrown away.
    return this.file.get_type(this.resolve(path)) >= 0
  }
 
  keys(path: string): string[] {
    const entity = this.entity(path)
    if (entity === null || !(entity instanceof this.h5.Group)) return []
    return entity.keys()
  }
 
  isSoftLink(path: string): boolean {
    // Guarded by `has`: `get_symbolic_link` on a missing path prints an HDF5
    // diagnostic block to stderr before returning null, which is noise the
    // caller never asked for.
    if (!this.has(path)) return false
    const target = this.file.get_link(this.resolve(path))
    return typeof target === 'string' && target.length > 0
  }
 
  datasetLength(path: string): number {
    const shape = this.dataset(path).metadata.shape
    return shape && shape.length > 0 ? shape[0] : 0
  }
 
  dtype(path: string): string {
    const raw = this.dataset(path).dtype
    if (typeof raw !== 'string') {
      throw new MetaCoolerFormatError(
        `${this.describe(path)} has a compound datatype, which this reader does not read`,
        { uri: this.uri, path: this.resolve(path) },
      )
    }
    return raw
  }
 
  readNumeric(path: string, lo?: number, hi?: number): Float64Array {
    const [ds, start, stop] = this.numericRange(path, lo, hi)
    return readNumericSlice(ds, start, stop, this.heap)
  }
 
  readInt32(path: string, lo?: number, hi?: number): Int32Array {
    const [ds, start, stop] = this.numericRange(path, lo, hi)
    return readInt32Slice(ds, start, stop, this.heap)
  }
 
  /** The shared preamble of the two numeric reads: classify, then clamp. */
  private numericRange(
    path: string,
    lo?: number,
    hi?: number,
  ): [H5wasmDataset, number, number] {
    const ds = this.dataset(path)
    if (classify(this.h5, ds.metadata) !== 'number') {
      throw new MetaCoolerFormatError(
        `${this.describe(path)} is ${String(ds.dtype)}, not a numeric dataset`,
        { uri: this.uri, path: this.resolve(path), dtype: String(ds.dtype) },
      )
    }
    const [start, stop] = this.range(ds, lo, hi)
    return [ds, start, stop]
  }
 
  readStrings(path: string, lo?: number, hi?: number): string[] {
    const ds = this.dataset(path)
    const md = ds.metadata
    if (classify(this.h5, md) !== 'string') {
      throw new MetaCoolerFormatError(
        `${this.describe(path)} is ${String(ds.dtype)}, not a string dataset`,
        { uri: this.uri, path: this.resolve(path), dtype: String(ds.dtype) },
      )
    }
    const [start, stop] = this.range(ds, lo, hi)
    return readStringSlice(this.h5, this.file, ds, start, stop)
  }
 
  readTable(path: string, lo?: number, hi?: number, columns?: readonly string[]): Table {
    const entity = this.entity(path)
    if (entity === null || !(entity instanceof this.h5.Group)) {
      throw new MetaCoolerFormatError(`${this.describe(path)} is not a table group`, {
        uri: this.uri,
        path: this.resolve(path),
      })
    }
    const label = this.resolve(path).slice(this.group === '/' ? 1 : this.group.length + 1)
    return readTableGroup(this.h5, this.file, entity, label, lo, hi, columns)
  }
 
  at(path: string): Hdf5Handle {
    const rooted = this.resolve(path)
    // `borrowed`, so this view's `close` is inert — see `Hdf5Handle.at`.
    const view = new Hdf5File(
      this.uri,
      rooted,
      this.h5,
      this.file,
      this.source,
      this.rootEntries,
      this.rootEntryGroup,
      true,
    )
    return view
  }
 
  close(): void {
    // A borrowed view shares the mount with the handle it came from; closing
    // it here would pull the file out from under that one (`Hdf5Handle.at`).
    if (this.closed || this.borrowed) return
    this.closed = true
    try {
      this.file.close()
    } finally {
      this.source.close()
    }
  }
 
  // -------------------------------------------------------------------------
  // internals
  // -------------------------------------------------------------------------
 
  /** Resolve a caller path against the rooted group, always absolute. */
  private resolve(path: string): string {
    const relative = path.replace(/^\/+/, '')
    if (relative === '') return this.group
    return normalise(this.group === '/' ? `/${relative}` : `${this.group}/${relative}`)
  }
 
  private describe(path: string): string {
    return `${this.uri} :: ${this.resolve(path)}`
  }
 
  private entity(path: string) {
    this.assertOpen()
    const resolved = this.resolve(path)
    if (resolved === '/') return this.file
    return this.file.get(resolved)
  }
 
  private dataset(path: string): H5wasmDataset {
    const entity = this.entity(path)
    if (entity === null) {
      throw new MetaCoolerFormatError(`${this.describe(path)} does not exist`, {
        uri: this.uri,
        path: this.resolve(path),
      })
    }
    if (!(entity instanceof this.h5.Dataset)) {
      throw new MetaCoolerFormatError(`${this.describe(path)} is not a dataset`, {
        uri: this.uri,
        path: this.resolve(path),
      })
    }
    return entity
  }
 
  /** Clamp a caller-supplied `[lo, hi)` onto a 1-D dataset's real extent. */
  private range(ds: H5wasmDataset, lo?: number, hi?: number): [number, number] {
    const shape = ds.metadata.shape
    if (!shape || shape.length !== 1) {
      throw new MetaCoolerFormatError(
        `/${ds.path.replace(/^\//, '')} is ${shape ? `${shape.length}-D` : 'scalar'}; ` +
          'this reader only slices 1-D columns',
        { path: ds.path, shape },
      )
    }
    const total = shape[0]
    const start = Math.min(Math.max(Math.trunc(lo ?? 0), 0), total)
    const stop = Math.min(Math.max(Math.trunc(hi ?? total), start), total)
    return [start, stop]
  }
 
  /**
   * Decode one attribute.
   *
   * `json_value` already does the right thing for everything h5py writes as an
   * attribute — it narrows BigInt to Number, unwraps scalars, and turns an
   * h5py boolean enum back into `true`/`false`. The exception is a
   * *fixed-length* string attribute, which lands in h5wasm's ASCII decode path;
   * those are re-read from raw bytes for the same reason string columns are.
   */
  private decodeAttr(
    objectPath: string,
    name: string,
    md: Metadata,
    jsonValue: unknown,
  ): AttrValue {
    if (md.type === this.h5.Module.H5T_class_t.H5T_STRING.value && !md.vlen) {
      const decoded = this.readFixedStringAttr(objectPath, name, md)
      if (decoded !== null) return decoded
    }
    return coerceAttr(jsonValue)
  }
 
  private readFixedStringAttr(
    objectPath: string,
    name: string,
    md: Metadata,
  ): string | string[] | null {
    const count = md.total_size
    const nbytes = md.size * count
    if (nbytes <= 0) return null
    const runtime = this.h5.Module
    const ptr = runtime._malloc(nbytes)
    if (ptr === 0) return null
    let bytes: Uint8Array
    try {
      runtime.get_attribute_data(this.file.file_id, objectPath, name, BigInt(ptr))
      bytes = runtime.HEAPU8.slice(ptr, ptr + nbytes)
    } finally {
      runtime._free(ptr)
    }
    const items: string[] = []
    for (let i = 0; i < count; i++) {
      let item = bytes.subarray(i * md.size, (i + 1) * md.size)
      const nul = item.indexOf(0)
      if (nul > -1) item = item.subarray(0, nul)
      items.push(UTF8.decode(item))
    }
    const shape = md.shape
    return shape && shape.length > 0 ? items : (items[0] ?? '')
  }
 
  private assertOpen(): void {
    if (this.closed) {
      throw new MetaCoolerFormatError(`${this.uri} is closed`, { uri: this.uri })
    }
  }
}
 
function describe(cause: unknown): string {
  return cause instanceof Error ? cause.message : String(cause)
}
 
/** Collapse repeated separators and strip a trailing slash, keeping the root. */
function normalise(path: string): string {
  const collapsed = `/${path}`.replace(/\/+/g, '/').replace(/(.)\/$/, '$1')
  return collapsed === '' ? '/' : collapsed
}
 
/** Narrow an h5wasm JSON-compatible attribute value onto {@link AttrValue}. */
function coerceAttr(value: unknown): AttrValue {
  if (value === null || value === undefined) return null
  if (typeof value === 'bigint') return Number(value)
  if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
    return value
  }
  if (Array.isArray(value)) {
    if (value.every((v) => typeof v === 'string')) return value as string[]
    const numeric = (v: unknown) =>
      typeof v === 'number' || typeof v === 'boolean' || typeof v === 'bigint'
    if (value.every(numeric)) return value.map((v) => Number(v))
    return value.map((v) => String(v))
  }
  return null
}