// Holding a database statement open across an `await` without letting Node
// call it an unhandled rejection.
//
// ---------------------------------------------------------------------------
// THE BUG THIS EXISTS TO KILL
// ---------------------------------------------------------------------------
//
// Eleven tests across ap/ar/immutability/inventory prove that two transactions
// racing on the same row SERIALIZE — that Postgres's row lock, not luck, is
// what stops a duplicate check number, an over-applied invoice, an oversold
// item, or an edit sneaking past a concurrent post. They all have the same
// shape, and it was written like this:
//
//     await a.query("BEGIN");
//     await a.query(...);                       // A takes the lock
//     const pending = b.query(...);             // B blocks behind it
//     await new Promise((r) => setTimeout(r, 300));
//     await a.query("COMMIT");                  // B unblocks
//     await expect(pending).rejects.toThrow(/over-applied/);
//
// Look at the gap. `pending` is a promise that is EXPECTED TO REJECT, and for
// ~300ms plus a COMMIT round-trip nothing is listening to it. If it rejects
// anywhere in that window — B never reached the lock, B's connection dropped, a
// lock_timeout or statement_timeout fired, the query was malformed, the machine
// was loaded enough to reorder the two round-trips — Node's rule applies: a
// promise that rejects with no handler attached by the end of the microtask
// checkpoint is an UNHANDLED REJECTION.
//
// Under vitest that is not a test failure, and the difference matters. On
// vitest 4.1.10 it lands as an "Unhandled Rejection" in the Errors section: NO
// test is marked failed, the offending test's own assertion never runs, and the
// output's only clue to which test caused it is a guess — verbatim, "The latest
// test that might've caused the error is ...". Vitest prints its own warning
// beside it: "This might cause false positive tests." When the rejection
// instead kills the fork worker, that file's results stop appearing in the
// totals altogether and no file name is printed anywhere. It was reproduced
// three times while other work ran on this machine and traced to ap.test.ts;
// idle-machine runs are clean, which is exactly why it survived this long.
// (server/src/db.ts's pool 'error' listeners were the same bug in a different
// costume — see the long comment there.)
//
// Reproduced deterministically before this fix, by setting `lock_timeout` on
// the racing connection so it fails INSIDE the 300ms window — the same
// condition machine load produced by accident. Before: one unhandled rejection,
// no attribution, and the real problem (B never reached the lock) nowhere in
// the output. After: a named test failure that says exactly that, with the
// Postgres error attached as its cause.
//
// For this project specifically that is intolerable. These are the CLAUDE.md
// §11 invariant tests: the ones that prove every journal entry balances and
// that one company cannot read another's books. A suite that can silently drop
// them while reporting success is worse than a suite that fails.
//
// ---------------------------------------------------------------------------
// THE FIX: IMPOSSIBLE, NOT UNLIKELY
// ---------------------------------------------------------------------------
//
// Widening the window (asserting sooner, sleeping less) would only make the
// race rarer, and a rarer heisenbug in the invariant suite is a WORSE outcome
// than a frequent one. So the handler is attached SYNCHRONOUSLY, on the same
// tick the promise is created, before this function returns. From that moment
// the promise can never be unhandled, whatever it does and whenever it does it.
// The outcome is recorded and replayed later by settled(), so the assertion the
// test actually cares about is completely unchanged — `.rejects.toThrow(...)`
// still sees the real error object, with its real message and its real cause.
//
// Nothing here weakens what is being tested. The concurrency assertion stays;
// stillBlocked() below makes it STRONGER.

/** What the wrapped promise did, once it has done it. */
type Outcome<T> =
  | { ok: true; value: T }
  | { ok: false; error: unknown };

export interface InFlight<T> {
  /**
   * The original promise's outcome, replayed exactly: resolves with its value,
   * or rejects with its error (the same object, so `.rejects.toThrow(/.../)`
   * and `cause` chains behave identically to awaiting the original).
   */
  settled(): Promise<T>;

  /**
   * Throws unless the statement is STILL waiting on the database.
   *
   * Call this after the deliberate pause and before the racing transaction
   * commits. Every one of these tests is built on the premise that B blocks on
   * a row lock A is holding — the surrounding comments say so in as many words
   * ("b blocks on the entry row lock until a commits", "it must block on the
   * invoice lock, not race past the deferred check"). Until now that premise
   * was assumed. Asserting it converts the exact condition that used to kill
   * the worker — B settling early — into a named, attributable test failure
   * that says which test and why.
   *
   * `label` names the statement in the failure message; there are two racing
   * statements in some of these tests and "the pending statement" is not enough
   * to find one at 5pm on a Friday.
   */
  stillBlocked(label: string): void;
}

/**
 * Attaches a handler to `promise` immediately and returns a handle for reading
 * its outcome later.
 *
 * Use it at the point of creation — `inFlight(b.query(...))`, not
 * `const p = b.query(...); ... inFlight(p)`. Wrapping late reintroduces exactly
 * the window this removes, so the call is written inline at every site.
 */
export function inFlight<T>(promise: Promise<T>): InFlight<T> {
  let outcome: Outcome<T> | null = null;

  // The whole fix, in one expression. Two handlers, attached synchronously:
  // `promise` now has a rejection handler for the rest of its life, so Node
  // will never report it as unhandled no matter how the timing falls out.
  //
  // `.then(onFulfilled, onRejected)` rather than `.catch()`: the fulfilment
  // path has to be recorded too (three of these races expect B to SUCCEED once
  // it is unblocked — the average-cost and check-numbering tests read B's
  // returned row), and a two-argument .then cannot itself produce a rejected
  // promise here because neither handler throws.
  const done: Promise<void> = promise.then(
    (value) => { outcome = { ok: true, value }; },
    (error: unknown) => { outcome = { ok: false, error }; },
  );

  return {
    async settled(): Promise<T> {
      await done;
      if (outcome === null) {
        // Unreachable: `done` only resolves after one of the two handlers above
        // has assigned. Stated rather than assumed, because silently returning
        // `undefined as T` from a helper the invariant suite depends on is the
        // kind of thing that makes a later bug take a day instead of an hour.
        throw new Error("inFlight: the wrapped promise settled without recording an outcome");
      }
      if (outcome.ok) return outcome.value;
      throw outcome.error;
    },

    stillBlocked(label: string): void {
      if (outcome === null) return; // still waiting on the lock — as intended
      if (outcome.ok) {
        throw new Error(
          `${label} was expected to be blocked on the other transaction's row lock, but it had already ` +
          "completed. The two statements did not serialize, so this test proved nothing about the race " +
          "it exists to test.",
        );
      }
      const message = outcome.error instanceof Error ? outcome.error.message : String(outcome.error);
      throw new Error(
        `${label} was expected to be blocked on the other transaction's row lock, but it had already ` +
        `failed with: ${message}. That means it never reached the lock — the failure above is the real ` +
        "problem, not the concurrency this test is about.",
        { cause: outcome.error },
      );
    },
  };
}
