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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
186 lines · 6.1 KB Raw History
// Record writes that arrive at the same time, as a CI runner sends them: a
// burst of creates, then updates and deletes of the same records. Each write
// is its own commit, and the commits must form one chain.

import { PersonalDataServer } from '@pdsjs/core';
import { parseCarFile } from '@pdsjs/core/car';
import { buildMst } from '@pdsjs/core/mst';
import { cborDecode, parseCommitBlock } from '@pdsjs/core/repo';
import { beforeEach, describe, expect, test } from 'vitest';
import {
  createMemorySharedStorage,
  createMemoryStorage,
} from './helpers/memory-storage.js';

const DID = 'did:plc:concurrenttestaaaaaaaaaa';
const PASSWORD = 'test-password';
const COLLECTION = 'dev.knotless.check';

describe('concurrent record writes', () => {
  /** @type {ReturnType<typeof createMemoryStorage>} */
  let storage;
  /** @type {PersonalDataServer} */
  let pds;
  /** @type {string} */
  let token;

  beforeEach(async () => {
    storage = createMemoryStorage();
    pds = new PersonalDataServer({
      actorStorage: /** @type {any} */ (storage),
      sharedStorage: /** @type {any} */ (createMemorySharedStorage()),
      blobs: {
        async get() {
          return null;
        },
        async put() {},
        async delete() {},
      },
      jwtSecret: 'test-secret',
      hostname: 'pds.example.com',
      password: PASSWORD,
    });

    await pds.fetch(
      new Request(`https://pds.example.com/init?did=${DID}`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          did: DID,
          privateKey: Buffer.from(
            crypto.getRandomValues(new Uint8Array(32)),
          ).toString('hex'),
          handle: 'concurrent.test',
          password: PASSWORD,
        }),
      }),
    );

    const sessionRes = await pds.fetch(
      new Request(
        'https://pds.example.com/xrpc/com.atproto.server.createSession',
        {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ identifier: DID, password: PASSWORD }),
        },
      ),
    );
    const sessionBody = await sessionRes.json();
    expect(sessionBody.accessJwt, 'session setup failed').toBeTruthy();
    token = sessionBody.accessJwt;
  });

  /**
   * @param {string} method
   * @param {object} body
   */
  async function write(method, body) {
    const res = await pds.fetch(
      new Request(`https://pds.example.com/xrpc/com.atproto.repo.${method}`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${token}`,
        },
        body: JSON.stringify({ repo: DID, collection: COLLECTION, ...body }),
      }),
    );
    return { status: res.status, body: await res.json() };
  }

  /** @param {string} status */
  const check = (status) => ({ status, createdAt: '2026-10-23T23:54:00Z' });

  /**
   * Every commit event in sequence order, decoded, with its commit block, as
   * the event carries it: a later commit drops it from storage.
   */
  function commitChain() {
    const events = storage._events.filter((e) => e.commit_cid !== '');
    return events.map((e) => {
      const evt = cborDecode(e.evt);
      const bytes = /** @type {Uint8Array} */ (
        parseCarFile(evt.blocks).blocks.get(e.commit_cid)
      );
      const { data, prev } = parseCommitBlock(bytes);
      return { seq: e.seq, evt, data, prev };
    });
  }

  /**
   * Each commit names the one before it: as `prev` in the signed block, and
   * as `since` and `prevData` on its firehose event.
   */
  async function expectContinuousChain() {
    const chain = commitChain();
    const seqs = chain.map((c) => c.seq);
    expect(new Set(seqs).size).toBe(seqs.length);
    expect([...seqs].sort((a, b) => a - b)).toEqual(seqs);
    for (let i = 1; i < chain.length; i++) {
      const before = chain[i - 1];
      const after = chain[i];
      expect(after.evt.since, `commit ${i} since`).toBe(before.evt.rev);
      expect(after.evt.prevData?.toString(), `commit ${i} prevData`).toBe(
        before.data,
      );
      expect(after.prev, `commit ${i} prev`).toBe(before.evt.commit);
    }
    const head = await storage.getLatestCommit();
    expect(head?.cid).toBe(chain[chain.length - 1].evt.commit);
    expect(chain[chain.length - 1].data).toBe(
      await buildMst(await storage.listAllRecords(), async () => {}),
    );
    return chain;
  }

  test('a burst of creates, updates and deletes commits as one chain', async () => {
    const first = Array.from({ length: 8 }, (_, i) => `check${i}`);
    const created = await Promise.all(
      first.map((rkey) =>
        write('createRecord', { rkey, record: check('running') }),
      ),
    );
    for (const res of created) expect(res.status).toBe(200);

    const second = Array.from({ length: 4 }, (_, i) => `later${i}`);
    const mixed = await Promise.all([
      ...first
        .slice(0, 5)
        .map((rkey) => write('putRecord', { rkey, record: check('success') })),
      ...first.slice(5).map((rkey) => write('deleteRecord', { rkey })),
      ...second.map((rkey) =>
        write('putRecord', { rkey, record: check('running') }),
      ),
    ]);
    for (const res of mixed) expect(res.status).toBe(200);

    const chain = await expectContinuousChain();
    expect(chain.length).toBe(first.length + mixed.length);

    for (const rkey of [...first.slice(0, 5), ...second]) {
      const held = await storage.getRecord(`at://${DID}/${COLLECTION}/${rkey}`);
      expect(held, rkey).toBeTruthy();
    }
    for (const rkey of first.slice(5)) {
      expect(
        await storage.getRecord(`at://${DID}/${COLLECTION}/${rkey}`),
      ).toBeNull();
    }
    expect((await storage.listAllRecords()).length).toBe(9);
  });

  test('concurrent creates at one key commit one create and refuse the rest', async () => {
    const results = await Promise.all(
      ['a', 'b', 'c', 'd'].map((status) =>
        write('createRecord', { rkey: 'same', record: check(status) }),
      ),
    );
    expect(results.map((res) => res.status).sort()).toEqual([
      200, 400, 400, 400,
    ]);

    const chain = await expectContinuousChain();
    expect(chain.map((c) => c.evt.ops[0].action)).toEqual(['create']);
  });
});