~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
224 lines · 7.5 KB Raw History
/**
 * The bundle object index, checked against real git: every object the pack
 * reader materializes must appear in the index with the extent, size, type
 * and delta base a standalone reader needs.
 */

import { execFile } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { promisify } from 'node:util';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { parseBundle } from '../src/bundle.js';
import { indexPacks } from '../src/objects.js';
import {
  BASE_EXTERNAL,
  BASE_NONE,
  buildBundleIndex,
  decodePackIndex,
  encodePackIndex,
} from '../src/pack-index.js';

const execFileAsync = promisify(execFile);

const env = {
  .../** @type {Record<string, string>} */ (process.env),
  GIT_AUTHOR_NAME: 'Test',
  GIT_AUTHOR_EMAIL: 'test@example.com',
  GIT_COMMITTER_NAME: 'Test',
  GIT_COMMITTER_EMAIL: 'test@example.com',
};

/**
 * @param {string} cwd
 * @param {string[]} args
 */
async function runGit(cwd, args) {
  const { stdout } = await execFileAsync('git', args, { cwd, env });
  return stdout.trim();
}

/** @type {string} */
let dir = '';
/** @type {string} */
let repo = '';
/** @type {Uint8Array} */
let full = new Uint8Array();
/** @type {Uint8Array} */
let incremental = new Uint8Array();

beforeAll(async () => {
  dir = mkdtempSync(join(tmpdir(), 'pdsjs-pack-index-'));
  repo = join(dir, 'repo');
  await runGit(dir, ['init', '-b', 'main', repo]);

  // Enough repeated content that the pack holds deltas.
  const lines = Array.from({ length: 200 }, (_, i) => `line ${i}`).join('\n');
  writeFileSync(join(repo, 'a.txt'), `${lines}\n`);
  writeFileSync(join(repo, 'b.txt'), `${lines}\nb\n`);
  await runGit(repo, ['add', '.']);
  await runGit(repo, ['commit', '-m', 'one']);
  const first = await runGit(repo, ['rev-parse', 'HEAD']);
  await runGit(repo, ['bundle', 'create', join(dir, 'b1.bundle'), 'main']);

  writeFileSync(join(repo, 'a.txt'), `${lines}\nchanged\n`);
  await runGit(repo, ['add', '.']);
  await runGit(repo, ['commit', '-m', 'two']);
  await runGit(repo, [
    'bundle',
    'create',
    join(dir, 'b2.bundle'),
    `^${first}`,
    'main',
  ]);

  full = new Uint8Array(readFileSync(join(dir, 'b1.bundle')));
  incremental = new Uint8Array(readFileSync(join(dir, 'b2.bundle')));
});

afterAll(() => {
  rmSync(dir, { recursive: true, force: true });
});

describe('buildBundleIndex', () => {
  it('lists every object the pack reader materializes', async () => {
    const index = await buildBundleIndex(full);
    const objects = await indexPacks([parseBundle(full).pack]);
    expect(index.entries.length).toBe(objects.size);
    for (const entry of index.entries) {
      const object = objects.get(entry.sha);
      expect(object, entry.sha).toBeDefined();
      expect(entry.type).toBe(object?.type);
      expect(entry.size).toBe(object?.data.length);
    }
  });

  it('reports the pack start and ascending entry extents', async () => {
    const index = await buildBundleIndex(full);
    const { pack } = parseBundle(full);
    expect(index.packStart).toBe(full.length - pack.length);
    // The first entry follows the 12-byte pack header; extents ascend and
    // the last one ends before the 20-byte pack trailer.
    expect(index.entries[0].offset).toBe(12);
    for (let i = 1; i < index.entries.length; i++) {
      expect(index.entries[i].offset).toBeGreaterThan(
        index.entries[i - 1].offset,
      );
    }
    const last = index.entries[index.entries.length - 1];
    expect(last.offset).toBeLessThan(pack.length - 20);
  });

  it('points every in-pack delta base at a listed entry', async () => {
    const index = await buildBundleIndex(full);
    const offsets = new Set(index.entries.map((entry) => entry.offset));
    let deltas = 0;
    for (const entry of index.entries) {
      if (entry.base === BASE_NONE) continue;
      deltas++;
      if (entry.base !== BASE_EXTERNAL) {
        expect(offsets.has(entry.base), `base of ${entry.sha}`).toBe(true);
      }
    }
    expect(deltas).toBeGreaterThan(0);
  });

  it('marks a cross-bundle base external and names its target', async () => {
    // The incremental bundle rewrites a.txt, so its pack deltas against
    // prerequisite objects it does not contain. Building its index needs
    // those bases, which come from the chain's earlier pack here.
    const prior = await indexPacks([parseBundle(full).pack]);
    const index = await buildBundleIndex(incremental, async (sha) => {
      return prior.get(sha) ?? null;
    });
    const offsets = new Set(index.entries.map((entry) => entry.offset));
    let external = 0;
    for (const entry of index.entries) {
      if (entry.base === BASE_NONE) continue;
      if (entry.base === BASE_EXTERNAL) {
        external++;
        continue;
      }
      expect(offsets.has(entry.base), `base of ${entry.sha}`).toBe(true);
    }
    expect(external).toBeGreaterThan(0);
  });

  it('round-trips through encode and decode', async () => {
    const index = await buildBundleIndex(full);
    const decoded = decodePackIndex(encodePackIndex(index));
    expect(decoded).toEqual(index);
  });

  it('rejects bytes that are not an index', () => {
    expect(() => decodePackIndex(new Uint8Array([1, 2, 3]))).toThrow(
      /not a pack index/,
    );
    expect(() => decodePackIndex(full.subarray(0, 64))).toThrow(
      /not a pack index/,
    );
  });
});

describe('indexPacks retention', () => {
  /**
   * Every entry the walk names, in order, whatever it keeps.
   * @param {object} [options]
   */
  async function walk(options) {
    /** @type {Array<{sha: string, type: string, size: number}>} */
    const seen = [];
    const kept = await indexPacks(
      [parseBundle(full).pack],
      (sha, meta) => seen.push({ sha, type: meta.type, size: meta.size }),
      undefined,
      options,
    );
    return { seen, kept };
  }

  it('names the same objects whatever it retains', async () => {
    const all = await walk({ retain: 'all' });
    const none = await walk({ retain: 'none' });
    expect(none.seen).toEqual(all.seen);
    expect(none.kept.size).toBe(0);
    expect(all.kept.size).toBe(all.seen.length);
  });

  it('keeps the commit graph alone for a history walk', async () => {
    const { seen, kept } = await walk({ retain: 'commits' });
    const history = seen.filter(
      (entry) => entry.type === 'commit' || entry.type === 'tag',
    );
    expect(history.length).toBeGreaterThan(0);
    expect(kept.size).toBe(history.length);
    for (const entry of history) {
      expect(kept.get(entry.sha)?.type, entry.sha).toBe(entry.type);
    }
  });

  it('builds an evicted delta base again from the pack', async () => {
    // A one-byte budget keeps no base between entries, so every delta walks
    // its chain back down to a whole object.
    const all = await walk({ retain: 'all' });
    const starved = await walk({ retain: 'none', cacheBytes: 1 });
    expect(starved.seen).toEqual(all.seen);
  });

  it('resolves a cross-bundle base under a starved cache', async () => {
    const prior = await indexPacks([parseBundle(full).pack]);
    const index = await buildBundleIndex(incremental, async (sha) => {
      return prior.get(sha) ?? null;
    });
    /** @type {string[]} */
    const seen = [];
    await indexPacks(
      [parseBundle(incremental).pack],
      (sha) => seen.push(sha),
      async (sha) => prior.get(sha) ?? null,
      { retain: 'none', cacheBytes: 1 },
    );
    expect(seen).toEqual(index.entries.map((entry) => entry.sha));
  });
});