fission-suite/webnative

View on GitHub
src/fs/v3/PublicRootWasm.ts

Summary

Maintainability
D
2 days
Test Coverage
import { CID } from "multiformats"
import { default as init, PublicDirectory, PublicFile, PublicNode } from "wnfs"

import * as Depot from "../../components/depot/implementation.js"
import * as Manners from "../../components/manners/implementation.js"

import { WASM_WNFS_VERSION } from "../../common/version.js"
import { Segments as Path } from "../../path/index.js"

import { UnixTree, Puttable, File, Links, PuttableUnixTree } from "../types.js"
import { BlockStore, DepotBlockStore } from "./DepotBlockStore.js"
import { BaseFile } from "../base/file.js"
import { Metadata } from "../metadata.js"


// This is some global mutable state to work around global mutable state
// issues with wasm-bindgen. It's important we *never* accidentally initialize the
// "wnfs" Wasm module twice.
let initialized = false

async function loadWasm({ manners }: Dependencies) {
  // MUST be prevented from initializing twice:
  // https://github.com/oddsdk/ts-odd/issues/429
  // https://github.com/rustwasm/wasm-bindgen/issues/3307
  if (initialized) return
  initialized = true

  manners.log(`⏬ Loading WNFS WASM`)
  const before = performance.now()
  // init accepts Promises as arguments
  await init(manners.wnfsWasmLookup(WASM_WNFS_VERSION))
  const time = performance.now() - before
  manners.log(`🧪 Loaded WNFS WASM (${time.toFixed(0)}ms)`)
}

type Dependencies = {
  depot: Depot.Implementation
  manners: Manners.Implementation
}

interface DirEntry {
  name: string
  metadata: {
    version: "3.0.0"
    unixMeta: {
      created: number
      modified: number
      mode: number
      kind: "raw" | "dir" | "file" | "metadata" | "symlink" | "hamtShard"
    }
  }
}

interface OpResult<A> {
  rootDir: PublicDirectory
  result: A
}



// ROOT


export class PublicRootWasm implements UnixTree, Puttable {

  dependencies: Dependencies
  root: Promise<PublicDirectory>
  lastRoot: PublicDirectory
  store: BlockStore
  readOnly: boolean

  constructor(dependencies: Dependencies, root: PublicDirectory, store: BlockStore, readOnly: boolean) {
    this.dependencies = dependencies
    this.root = Promise.resolve(root)
    this.lastRoot = root
    this.store = store
    this.readOnly = readOnly
  }

  static async empty(dependencies: Dependencies): Promise<PublicRootWasm> {
    await loadWasm(dependencies)
    const store = new DepotBlockStore(dependencies.depot)
    const root = new PublicDirectory(new Date())
    return new PublicRootWasm(dependencies, root, store, false)
  }

  static async fromCID(dependencies: Dependencies, cid: CID): Promise<PublicRootWasm> {
    await loadWasm(dependencies)
    const store = new DepotBlockStore(dependencies.depot)
    const root = await PublicDirectory.load(cid.bytes, store)
    return new PublicRootWasm(dependencies, root, store, false)
  }

  private async atomically(fn: (root: PublicDirectory) => Promise<PublicDirectory>) {
    const root = await this.root
    this.root = fn(root)
    await this.root
  }

  private async withError<T>(operation: Promise<T>, opDescription: string): Promise<T> {
    try {
      return await operation
    } catch (e) {
      console.error(`Error during WASM operation ${opDescription}:`)
      throw e
    }
  }

  async ls(path: Path): Promise<Links> {
    const root = await this.root

    const { result: node } = await this.withError(
      root.getNode(path, this.store),
      `ls(${path.join("/")})`
    ) as OpResult<PublicNode | null>

    if (node == null) {
      throw new Error(`Can't ls ${path.join("/")}: No such directory`)
    }

    if (!node.isDir()) {
      throw new Error(`Can't ls ${path.join("/")}: Not a directory`)
    }

    const directory = node.asDir()

    const { result: entries } = await this.withError(
      root.ls(path, this.store),
      `ls(${path.join("/")})`
    ) as OpResult<DirEntry[]>

    const result: Links = {}
    for (const entry of entries) {
      const node = await directory.lookupNode(entry.name, this.store) as PublicNode

      const cid = node.isFile()
        ? CID.decode(await node.asFile().store(this.store))
        : CID.decode(await node.asDir().store(this.store))

      result[ entry.name ] = {
        name: entry.name,
        isFile: entry.metadata.unixMeta.kind === "file",
        size: 0, // TODO size?
        cid,
      }
    }
    return result
  }

  async mkdir(path: Path): Promise<this> {
    await this.atomically(async root => {

      const { rootDir } = await this.withError(
        root.mkdir(path, new Date(), this.store),
        `mkdir(${path.join("/")})`
      ) as OpResult<null>

      return rootDir
    })

    return this
  }

  async cat(path: Path): Promise<Uint8Array> {
    const root = await this.root

    const { result: cidBytes } = await this.withError(
      root.read(path, this.store),
      `read(${path.join("/")})`
    ) as OpResult<Uint8Array>

    const cid = CID.decode(cidBytes)
    return this.dependencies.depot.getUnixFile(cid)
  }

  async add(path: Path, content: Uint8Array): Promise<this> {
    const { cid } = await this.dependencies.depot.putChunked(content)

    await this.atomically(async root => {
      const { rootDir } = await this.withError(
        root.write(path, cid.bytes, new Date(), this.store),
        `write(${path.join("/")})`
      ) as OpResult<null>

      return rootDir
    })

    return this
  }

  async rm(path: Path): Promise<this> {
    await this.atomically(async root => {
      const { rootDir } = await this.withError(
        root.rm(path, this.store),
        `rm(${path.join("/")})`
      ) as OpResult<null>

      return rootDir
    })

    return this
  }

  async mv(from: Path, to: Path): Promise<this> {
    await this.atomically(async root => {
      const { rootDir } = await this.withError(
        root.basicMv(from, to, new Date(), this.store),
        `basicMv(${from.join("/")}, ${to.join("/")})`
      ) as OpResult<null>

      return rootDir
    })

    return this
  }

  async get(path: Path): Promise<PuttableUnixTree | File | null> {
    const root = await this.root
    const { result: node } = await this.withError(
      root.getNode(path, this.store),
      `getNode(${path.join("/")})`
    ) as OpResult<PublicNode>

    if (node == null) {
      return null
    }

    if (node.isFile()) {
      const cachedFile = node.asFile()
      const content = await this.cat(path)
      const directory = path.slice(0, -1)
      const filename = path[ path.length - 1 ]

      return new PublicFileWasm(content, directory, filename, this, cachedFile)

    } else if (node.isDir()) {
      const cachedDir = node.asDir()

      return new PublicDirectoryWasm(this.readOnly, path, this, cachedDir)
    }

    throw new Error(`Unknown node type. Can only handle files and directories.`)
  }

  async exists(path: Path): Promise<boolean> {
    const root = await this.root

    try {
      await root.getNode(path, this.store)
      return true
    } catch {
      return false
    }
  }

  async historyStep(): Promise<PublicDirectory> {
    await this.atomically(async root => {
      const { rootDir: rebasedRoot } = await root.baseHistoryOn(this.lastRoot, this.store) as OpResult<null>
      this.lastRoot = root
      return rebasedRoot
    })
    return await this.root
  }

  async put(): Promise<CID> {
    const rebasedRoot = await this.historyStep()
    const cidBytes = await rebasedRoot.store(this.store) as Uint8Array
    return CID.decode(cidBytes)
  }

  async putDetailed(): Promise<Depot.PutResult> {
    return {
      cid: await this.put(),
      size: 0, // TODO figure out size
      isFile: false,
    }
  }

}



// DIRECTORY


export class PublicDirectoryWasm implements UnixTree, Puttable {
  readOnly: boolean

  private directory: string[]
  private publicRoot: PublicRootWasm
  private cachedDir: PublicDirectory

  constructor(readOnly: boolean, directory: string[], publicRoot: PublicRootWasm, cachedDir: PublicDirectory) {
    this.readOnly = readOnly
    this.directory = directory
    this.publicRoot = publicRoot
    this.cachedDir = cachedDir
  }

  private checkMutability(operation: string) {
    if (this.readOnly) throw new Error(`Directory is read-only. Cannot ${operation}`)
  }

  private async updateCache() {
    const root = await this.publicRoot.root
    const node = await root.getNode(this.directory, this.publicRoot.store)
    this.cachedDir = node.asDir()
  }

  get header(): { metadata: Metadata; previous?: CID } {
    return nodeHeader(this.cachedDir)
  }

  async ls(path: Path): Promise<Links> {
    return await this.publicRoot.ls([ ...this.directory, ...path ])
  }

  async mkdir(path: Path): Promise<this> {
    this.checkMutability(`mkdir at ${[ ...this.directory, ...path ].join("/")}`)
    await this.publicRoot.mkdir([ ...this.directory, ...path ])
    await this.updateCache()
    return this
  }

  async cat(path: Path): Promise<Uint8Array> {
    return await this.publicRoot.cat([ ...this.directory, ...path ])
  }

  async add(path: Path, content: Uint8Array): Promise<this> {
    this.checkMutability(`write at ${[ ...this.directory, ...path ].join("/")}`)
    await this.publicRoot.add([ ...this.directory, ...path ], content)
    await this.updateCache()
    return this
  }

  async rm(path: Path): Promise<this> {
    this.checkMutability(`remove at ${[ ...this.directory, ...path ].join("/")}`)
    await this.publicRoot.rm([ ...this.directory, ...path ])
    await this.updateCache()
    return this
  }

  async mv(from: Path, to: Path): Promise<this> {
    this.checkMutability(`mv from ${[ ...this.directory, ...from ].join("/")} to ${[ ...this.directory, ...to ].join("/")}`)
    await this.publicRoot.mv([ ...this.directory, ...from ], [ ...this.directory, ...to ])
    await this.updateCache()
    return this
  }

  async get(path: Path): Promise<PuttableUnixTree | File | null> {
    return await this.publicRoot.get([ ...this.directory, ...path ])
  }

  async exists(path: Path): Promise<boolean> {
    return await this.publicRoot.exists([ ...this.directory, ...path ])
  }

  async put(): Promise<CID> {
    await this.publicRoot.put()
    const root = await this.publicRoot.root
    const cidBytes: Uint8Array = await root.store(this.publicRoot.store)
    return CID.decode(cidBytes)
  }

  async putDetailed(): Promise<Depot.PutResult> {
    return {
      isFile: false,
      size: 0,
      cid: await this.put()
    }
  }

}



// FILE
// This is somewhat of a weird hack of providing a result for a `get()` operation.


export class PublicFileWasm extends BaseFile {
  private directory: string[]
  private filename: string
  private publicRoot: PublicRootWasm
  private cachedFile: PublicFile

  constructor(content: Uint8Array, directory: string[], filename: string, publicRoot: PublicRootWasm, cachedFile: PublicFile) {
    super(content)
    this.directory = directory
    this.filename = filename
    this.publicRoot = publicRoot
    this.cachedFile = cachedFile
  }

  private async updateCache() {
    const root = await this.publicRoot.root
    const node = await root.getNode([ ...this.directory, this.filename ], this.publicRoot.store)
    this.cachedFile = node.asFile()
  }

  get header(): { metadata: Metadata; previous?: CID } {
    return nodeHeader(this.cachedFile)
  }

  async updateContent(content: Uint8Array): Promise<this> {
    await super.updateContent(content)
    await this.updateCache()
    return this
  }

  async putDetailed(): Promise<Depot.PutResult> {
    const root = await this.publicRoot.root
    const path = [ ...this.directory, this.filename ]
    const { result: node } = await root.getNode(path, this.publicRoot.store) as OpResult<PublicNode>

    if (node == null) {
      throw new Error(`No file at /${path.join("/")}.`)
    }

    if (!node.isFile()) {
      throw new Error(`Not a file at /${path.join("/")}`)
    }

    const file = node.asFile()

    return {
      isFile: true,
      size: 0,
      cid: CID.decode(await file.store(this.publicRoot.store))
    }
  }

}

function nodeHeader(node: PublicFile | PublicDirectory): { metadata: Metadata; previous?: CID } {
  // There's some differences between the two.
  const meta = node.metadata()
  const metadata: Metadata = {
    isFile: meta.unixMeta.kind === "file",
    version: meta.version,
    unixMeta: {
      _type: meta.unixMeta.kind,
      ctime: Number(meta.unixMeta.created),
      mtime: Number(meta.unixMeta.modified),
      mode: meta.unixMeta.mode,
    }
  }

  const previous = node.previousCid()
  return previous == null ? { metadata } : {
    metadata,
    previous: CID.decode(previous),
  }
}