utils/lineupConflictGuard.jsjavascript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.snapshotLineupConflicts = snapshotLineupConflicts;
exports.withLineupConflictGuard = withLineupConflictGuard;
const sequelize_1 = require("sequelize");
const db_1 = __importDefault(require("../db/db.js"));
const errors_1 = require("./errors");
const winston_1 = require("../plugins/winston.js");
const transactionContext_1 = require("./transactionContext");
/**
 * @returns {boolean} True when the active database dialect is PostgreSQL, which
 * supports the advisory locks used to serialize concurrent guarded mutations.
 */
function isPostgres() {
    return db_1.default.getDialect() === "postgres";
}
/**
 * Take a transaction-scoped PostgreSQL advisory lock keyed to one choreography,
 * so concurrent guarded mutations on the same choreo run one at a time. No-op on
 * SQLite, which serializes writers and is only used by the sequential test suite.
 *
 * @param {Transaction} transaction - The transaction the lock is scoped to; it
 * is released automatically when the transaction ends.
 * @param {string} choreoId - The choreography whose mutations are serialized.
 * @returns {Promise<void>}
 */
async function acquireLock(transaction, choreoId) {
    if (!isPostgres()) {
        // SQLite serializes writers and the unit-test suite runs transactions
        // sequentially, so there is nothing to acquire.
        return;
    }
    await db_1.default.query(`SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, {
        replacements: [`lineup-guard:${choreoId}`],
        transaction,
    });
}
/**
 * Snapshot every lineup consistency violation currently present across the given
 * choreographies: overlapping lineups, members in two overlapping lineups, and
 * duplicate positions. Runs plain SELECTs inside the caller's transaction so the
 * result reflects uncommitted changes made earlier in the same transaction.
 *
 * @param {string[]} choreoIds - Choreographies to inspect. An empty array yields
 * an empty snapshot without querying.
 * @param {Transaction} transaction - The transaction the queries run in.
 * @returns {Promise<LineupConflictSnapshot>} The violations found, as canonical
 * key sets suitable for diffing against another snapshot.
 */
async function snapshotLineupConflicts(choreoIds, transaction) {
    const snapshot = {
        lineupOverlaps: new Set(),
        memberConflicts: new Set(),
        duplicatePositions: new Set(),
    };
    if (choreoIds.length === 0) {
        return snapshot;
    }
    const overlaps = (await db_1.default.query(`SELECT "a"."id" AS "aId", "b"."id" AS "bId"
       FROM "Lineups" AS "a"
       JOIN "Lineups" AS "b"
         ON "a"."ChoreoId" = "b"."ChoreoId"
        AND "a"."id" < "b"."id"
        AND "a"."startCount" <= "b"."endCount"
        AND "b"."startCount" <= "a"."endCount"
      WHERE "a"."ChoreoId" IN (:choreoIds)`, {
        replacements: { choreoIds },
        transaction,
        type: sequelize_1.QueryTypes.SELECT,
    }));
    for (const row of overlaps) {
        snapshot.lineupOverlaps.add(`lineupOverlap:${row.aId}:${row.bId}`);
    }
    const memberConflicts = (await db_1.default.query(`SELECT DISTINCT "pa"."MemberId" AS "memberId",
                     "la"."id" AS "lineupIdA",
                     "lb"."id" AS "lineupIdB"
       FROM "Positions" AS "pa"
       JOIN "Lineups" AS "la" ON "la"."id" = "pa"."LineupId"
       JOIN "Positions" AS "pb"
         ON "pb"."MemberId" = "pa"."MemberId"
        AND "pb"."LineupId" <> "pa"."LineupId"
       JOIN "Lineups" AS "lb" ON "lb"."id" = "pb"."LineupId"
      WHERE "la"."ChoreoId" IN (:choreoIds)
        AND "lb"."ChoreoId" = "la"."ChoreoId"
        AND "pa"."MemberId" IS NOT NULL
        AND "la"."startCount" <= "lb"."endCount"
        AND "lb"."startCount" <= "la"."endCount"`, {
        replacements: { choreoIds },
        transaction,
        type: sequelize_1.QueryTypes.SELECT,
    }));
    for (const row of memberConflicts) {
        const [first, second] = [row.lineupIdA, row.lineupIdB].sort();
        snapshot.memberConflicts.add(`memberConflict:${row.memberId}:${first}:${second}`);
    }
    const duplicates = (await db_1.default.query(`SELECT "LineupId" AS "lineupId", "MemberId" AS "memberId"
       FROM "Positions"
      WHERE "LineupId" IN (SELECT "id" FROM "Lineups" WHERE "ChoreoId" IN (:choreoIds))
        AND "MemberId" IS NOT NULL
      GROUP BY "LineupId", "MemberId"
     HAVING COUNT(*) > 1`, {
        replacements: { choreoIds },
        transaction,
        type: sequelize_1.QueryTypes.SELECT,
    }));
    for (const row of duplicates) {
        snapshot.duplicatePositions.add(`duplicatePosition:${row.lineupId}:${row.memberId}`);
    }
    return snapshot;
}
/**
 * Diff two snapshots and return the violation keys present in `after` but not in
 * `before` — i.e. the conflicts a mutation newly introduced. Pre-existing
 * violations appear in both and are omitted, which is what grandfathers them.
 *
 * @param {LineupConflictSnapshot} before - Snapshot taken before the mutation.
 * @param {LineupConflictSnapshot} after - Snapshot taken after the mutation.
 * @returns {string[]} Canonical keys of the newly introduced violations; empty
 * when the mutation introduced none.
 */
function describeNewViolations(before, after) {
    return [
        ...[...after.lineupOverlaps].filter((key) => !before.lineupOverlaps.has(key)),
        ...[...after.memberConflicts].filter((key) => !before.memberConflicts.has(key)),
        ...[...after.duplicatePositions].filter((key) => !before.duplicatePositions.has(key)),
    ];
}
/**
 * Run a mutation inside a transaction that enforces lineup consistency.
 *
 * The guard takes an advisory lock per choreography (so concurrent writers run
 * one at a time), snapshots existing violations, runs `work`, snapshots again,
 * and commits only if `work` introduced no new overlap, member conflict, or
 * duplicate position. Violations that already existed before `work` are
 * preserved in both snapshots and therefore allowed (stateless grandfathering),
 * so legacy data stays editable as long as the edit does not make it worse.
 *
 * `work` must perform its writes on the ambient transaction via
 * {@link getTransaction} so they are included in the "after" snapshot and roll
 * back together when a conflict is detected. When `choreoIds` contains no usable
 * id, `work` runs directly with no transaction, lock, or snapshot.
 *
 * @template T
 * @param {Array<string | null | undefined>} choreoIds - Choreographies affected
 * by the mutation; null/undefined/duplicate entries are filtered and the rest
 * are locked in sorted order to avoid deadlocks.
 * @param {function(): Promise<T>} work - The mutation to run under the guard.
 * @returns {Promise<T>} The value returned by `work`.
 * @throws {LineupConflictError} If `work` introduces a new lineup overlap,
 * member conflict, or duplicate position; the transaction is rolled back.
 */
async function withLineupConflictGuard(choreoIds, work) {
    const targets = [
        ...new Set(choreoIds.filter((id) => typeof id === "string" && !!id)),
    ].sort();
    if (targets.length === 0) {
        return work();
    }
    return db_1.default.transaction(async (transaction) => (0, transactionContext_1.runInTransaction)(transaction, async () => {
        for (const choreoId of targets) {
            await acquireLock(transaction, choreoId);
        }
        const before = await snapshotLineupConflicts(targets, transaction);
        const result = await work();
        const after = await snapshotLineupConflicts(targets, transaction);
        const introduced = describeNewViolations(before, after);
        if (introduced.length > 0) {
            throw new errors_1.LineupConflictError(`Lineup consistency conflict introduced: ${introduced.join(", ")}`);
        }
        winston_1.logger.debug(`Lineup conflict guard accepted mutation for ${JSON.stringify(targets)}`);
        return result;
    }));
}