testTransactions

tcmts.cli.test.e2e.data.testTransactions · #3mr6b5gg · term

transactions: all or nothing, isolated from other requests, savepoints, jobs on commit

import { okIn, tempDir } from "#codebase/tcmts/cli/test/e2e/harness";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";

export const testTransactions = () => {
  const dir = tempDir("data3");
  const tcm = (...args: string[]) => okIn(dir, ...args);
  tcm("init");
  tcm("lib", "add", "tcm", "server", "--at", "lib.server");
  fs.writeFileSync(
    path.join(dir, "scratch.ts"),
    `// tcm: namespace bank
import assert from "node:assert/strict";
import { table, transaction, enqueue, runJobs } from "#codebase/lib/server";

export type Account = { owner: string; balance: number };
export const accounts = table<Account>("accounts");
const tick = () => new Promise((r) => setTimeout(r, 20));

export async function transfer(from: string, to: string, amount: number): Promise<void> {
  await transaction(async () => {
    const a = (await accounts.get(from))!;
    const b = (await accounts.get(to))!;
    await accounts.put(from, { ...a, balance: a.balance - amount });
    await tick(); // halfway: nobody else may see this
    if (a.balance < amount) throw new Error("insufficient funds");
    await accounts.put(to, { ...b, balance: b.balance + amount });
  });
}
const balances = async (...ids: string[]) => Promise.all(ids.map(async (id) => (await accounts.get(id))?.balance));
const open = async (id: string, balance: number) => accounts.put(id, { owner: id, balance });

export const testCommitAndRollback = async () => {
  await open("a", 100);
  await open("b", 0);
  await transfer("a", "b", 30);
  assert.deepEqual(await balances("a", "b"), [70, 30]);
  await assert.rejects(transfer("a", "b", 500), /insufficient funds/);
  assert.deepEqual(await balances("a", "b"), [70, 30], "the debit was rolled back");
};

export const testIsolation = async () => {
  await open("c", 50);
  await open("d", 50);
  // started while the transfer is halfway: waits, then sees all of it
  const moving = transfer("c", "d", 10);
  await new Promise((r) => setTimeout(r, 5));
  const seen = await accounts.find();
  await moving;
  const sum = seen.filter((r) => r.id === "c" || r.id === "d").reduce((n, r) => n + r.value.balance, 0);
  assert.equal(sum, 100, "never a debit without its credit");
  assert.deepEqual(await balances("c", "d"), [40, 60]);
};

export const testSavepoints = async () => {
  await open("e", 1);
  await transaction(async () => {
    await accounts.update("e", (x) => ({ ...x, balance: 2 }));
    await assert.rejects(
      transaction(async () => {
        await accounts.put("e", { owner: "e", balance: 99 });
        throw new Error("inner fails");
      }),
      /inner fails/,
    );
    assert.equal((await accounts.get("e"))?.balance, 2, "only the inner writes were undone");
  });
  assert.equal((await accounts.get("e"))?.balance, 2);
};

const sent: string[] = [];
export async function notify(who: string): Promise<void> {
  sent.push(who);
}
export const testJobsOnCommit = async () => {
  await assert.rejects(transaction(async () => {
    enqueue(notify, "rolled back");
    throw new Error("no");
  }));
  await transaction(async () => {
    enqueue(notify, "committed");
  });
  await runJobs({ all: true });
  assert.deepEqual(sent, ["committed"]);
};
`,
  );
  tcm("add");
  assert.match(tcm("test", "bank"), /4 passed, 0 failed/);
  assert.match(tcm("view", "bank.transfer"), /runs on the server: it uses [^\n]*lib\.server\./);
}

uses