All files / src/adapters memory.ts

100% Statements 138/138
98.78% Branches 81/82
100% Functions 17/17
100% Lines 138/138

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 1821x 1x                         1x 1x     1x 1x   397x   397x 397x 397x   1x 397x 397x 397x 140x 810x 810x 810x 397x 8x 8x 8x 8x 8x 397x   1x 427x 427x 427x   1x 15658x 15658x 15658x 264426x 264426x 264426x 264426x 264426x 264426x 264426x 264426x 25693x     25693x 25693x 25693x 25693x 264426x 15658x 15658x   1x 3025x 3025x 2387x 3025x   1x 6185x 6185x   1x 1032x 1032x 1032x 391x 391x 391x 641x 1032x   1x 466x 466x 5229x 5229x 466x 2103x 2103x 466x           1x 56x 56x 56x 56x   1x 18x 18x   1x 11x 11x 11x 63x 63x 11x 8x 8x 11x 11x   1x 314x 314x 314x 314x 310x 310x 310x 310x 241x 25x 3x 3x 3x 3x       241x 18x 2x 2x 2x 314x   1x 4291x 4291x 4291x 4291x 7489x 7489x 4291x 4291x 4291x         1x 2x 2x 2x     1x 566x 566x 5320x 1749x 1749x 566x 566x 1x  
import { isInside, normalizePath } from '../path.js';
import { chunked, concat } from '../stream.js';
import type { ByteRange, VFSAdapter, VFSListEntry, VFSStat } from '../types.js';
 
export interface MemoryAdapterOptions {
  /**
   * Defaults to a monotonic wall clock: every write gets a strictly greater
   * `mtime` than the last, which keeps ordering unambiguous in tests and demos.
   */
  clock?: () => number;
  /** Initial contents. Values are decoded as UTF-8 when given as strings. */
  files?: Record<string, string | Uint8Array>;
}
 
const encoder = new TextEncoder();
const decoder = new TextDecoder();
 
/** In-memory backend — the reference implementation, and what tests run on. */
export class MemoryAdapter implements VFSAdapter {
  readonly name: string;
 
  private readonly entries = new Map<string, { data: Uint8Array; mtime: number }>();
  /** Directories created empty via mkdir(); implicit parents are not tracked. */
  private readonly dirs = new Set<string>();
  private readonly clock: () => number;
  private last = 0;
 
  constructor(name = 'memory', options: MemoryAdapterOptions = {}) {
    this.name = name;
    this.clock =
      options.clock ??
      (() => {
        this.last = Math.max(Date.now(), this.last + 1);
        return this.last;
      });
    for (const [path, value] of Object.entries(options.files ?? {})) {
      this.entries.set(normalizePath(path), {
        data: typeof value === 'string' ? encoder.encode(value) : value,
        mtime: this.clock(),
      });
    }
  }
 
  async mkdir(path: string): Promise<void> {
    const parts = normalizePath(path).split('/').filter(Boolean);
    for (let i = 1; i <= parts.length; i++) this.dirs.add(parts.slice(0, i).join('/'));
  }
 
  async list(path: string): Promise<VFSListEntry[]> {
    const dir = normalizePath(path);
    const seen = new Map<string, VFSListEntry>();
    for (const key of [...this.entries.keys(), ...this.dirs]) {
      if (!isInside(key, dir) || key === dir) continue;
      const rest = dir ? key.slice(dir.length + 1) : key;
      const slash = rest.indexOf('/');
      const name = slash === -1 ? rest : rest.slice(0, slash);
      const child = dir ? `${dir}/${name}` : name;
      const isDir = slash !== -1 || this.dirs.has(key);
      const known = seen.get(child);
      if (!known || (isDir && known.kind === 'file')) {
        const entry: VFSListEntry = { name, path: child, kind: isDir ? 'directory' : 'file' };
        // The map holds the bytes and the mtime already, so a caller that would
        // stat every entry (walk, a file browser) needs no second pass.
        const held = isDir ? undefined : this.entries.get(child);
        if (held) entry.stat = { kind: 'file', size: held.data.byteLength, mtime: held.mtime };
        seen.set(child, entry);
      }
    }
    return [...seen.values()];
  }
 
  async read(path: string): Promise<Uint8Array> {
    const entry = this.entries.get(normalizePath(path));
    if (!entry) throw new Error(`ENOENT: ${path}`);
    return entry.data.slice();
  }
 
  async write(path: string, data: Uint8Array): Promise<void> {
    this.entries.set(normalizePath(path), { data: data.slice(), mtime: this.clock() });
  }
 
  async append(path: string, data: Uint8Array): Promise<void> {
    const target = normalizePath(path);
    const held = this.entries.get(target);
    if (!held) {
      await this.write(target, data);
      return;
    }
    this.entries.set(target, { data: concat([held.data, data.slice()]), mtime: this.clock() });
  }
 
  async delete(path: string): Promise<void> {
    const target = normalizePath(path);
    for (const key of [...this.entries.keys()]) {
      if (key === target || key.startsWith(`${target}/`)) this.entries.delete(key);
    }
    for (const dir of [...this.dirs]) {
      if (dir === target || dir.startsWith(`${target}/`)) this.dirs.delete(dir);
    }
  }
 
  // Streaming here can never save memory — the file *is* memory. It exists so
  // that the engine's streaming paths are exercised by the adapter the tests
  // run on, and so range reads stay cheap.
 
  async readRange(path: string, range: ByteRange = {}): Promise<Uint8Array> {
    const entry = this.entries.get(normalizePath(path));
    if (!entry) throw new Error(`ENOENT: ${path}`);
    return entry.data.slice(range.start ?? 0, range.end ?? entry.data.byteLength);
  }
 
  async readStream(path: string, range: ByteRange = {}): Promise<ReadableStream<Uint8Array>> {
    return chunked(await this.readRange(path, range));
  }
 
  async writeStream(path: string): Promise<WritableStream<Uint8Array>> {
    const chunks: Uint8Array[] = [];
    return new WritableStream<Uint8Array>({
      write: (chunk) => {
        chunks.push(chunk.slice());
      },
      close: async () => {
        await this.write(path, concat(chunks));
      },
    });
  }
 
  async rename(oldPath: string, newPath: string): Promise<void> {
    const from = normalizePath(oldPath);
    const to = normalizePath(newPath);
    const entry = this.entries.get(from);
    if (entry) {
      this.entries.delete(from);
      this.entries.set(to, entry);
      return;
    }
    for (const key of [...this.entries.keys()]) {
      if (!key.startsWith(`${from}/`)) continue;
      const moved = this.entries.get(key) as { data: Uint8Array; mtime: number };
      this.entries.delete(key);
      this.entries.set(`${to}${key.slice(from.length)}`, moved);
    }
    // Empty folders are tracked apart from the files, so they have to move too.
    // Left behind, the directory would still stat at the old path and stat as
    // missing at the new one — and v2 syncs empty directories.
    for (const key of [...this.dirs]) {
      if (key !== from && !key.startsWith(`${from}/`)) continue;
      this.dirs.delete(key);
      this.dirs.add(`${to}${key.slice(from.length)}`);
    }
  }
 
  async stat(path: string): Promise<VFSStat | null> {
    const target = normalizePath(path);
    const entry = this.entries.get(target);
    if (entry) return { kind: 'file', size: entry.data.byteLength, mtime: entry.mtime };
    for (const key of this.entries.keys()) {
      if (key.startsWith(`${target}/`)) return { kind: 'directory', size: 0, mtime: 0 };
    }
    if (this.dirs.has(target)) return { kind: 'directory', size: 0, mtime: 0 };
    return target === '' ? { kind: 'directory', size: 0, mtime: 0 } : null;
  }
 
  // ------------------------------------------------------------- test aids
 
  /** Forces an `mtime`, to script clock skew or simultaneous edits. */
  setMtime(path: string, mtime: number): void {
    const entry = this.entries.get(normalizePath(path));
    if (entry) entry.mtime = mtime;
  }
 
  /** Working-folder contents as text, control folder excluded. */
  snapshot(): Record<string, string> {
    const out: Record<string, string> = {};
    for (const [path, entry] of this.entries) {
      if (path === '.vfs' || path.startsWith('.vfs/')) continue;
      out[path] = decoder.decode(entry.data);
    }
    return out;
  }
}