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;
}));
}