All files / src/server/services PreferenceStateService.ts

100% Statements 25/25
100% Branches 4/4
100% Functions 9/9
100% Lines 22/22

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119                                  99x 99x 99x 99x 99x                   67x               67x 128x   67x             67x                 67x 67x 111x                       67x                         67x         65x 84x       65x 65x 70x                     67x             33x      
import type { CollectionPreferenceItem, DecisionEvent, Item } from "@/types";
import type { CollectionPreferenceItemRepository } from "@/server/repositories/implementations/CollectionPreferenceItemRepository";
import type { DecisionEventRepository } from "@/server/repositories/implementations/DecisionEventRepository";
import type { SessionRepository } from "@/server/repositories/implementations/SessionRepository";
import type { ItemRepository } from "@/server/repositories/implementations/ItemRepository";
import type { ScoringService } from "@/server/services/ScoringService";
import { loadCuratorDecisionEvents } from "@/server/services/decision-log";
import {
  deriveOrder,
  toComparisonEvents,
  type ComparisonEvent,
  type RankItem,
} from "@/lib/insertion-ranking";
import { selectComparableItems } from "@/lib/compare-selection";
 
export class PreferenceStateService {
  constructor(
    private preferenceRepo: CollectionPreferenceItemRepository,
    private scoringService: ScoringService,
    private itemRepo: ItemRepository,
    private sessionRepo: SessionRepository,
    private decisionRepo: DecisionEventRepository,
  ) {}
 
  async recalculatePreferences(
    collectionId: string,
    curatorId: string,
  ): Promise<CollectionPreferenceItem[]> {
    // Read the curator's decision log ONCE and share it between bucket scoring
    // and the pairwise-comparison mapping — both consumers derive from the same
    // event set, so a single read is behavior-identical to the old double read.
    const events = await this.loadDecisionEvents(collectionId, curatorId);
 
    // purses-5g1g: load the collection's LIVE items ONCE (findByCollection
    // excludes soft-deleted rows and a purged item is gone entirely). This set
    // gates the derived preference set so a purged item — whose append-only
    // decision_events survive — can never be resurrected into a preference row
    // (an FK violation on preferences.item_id that would roll back the tap). The
    // same list feeds deriveOrderRanks, so it is read only once.
    const items = await this.itemRepo.findByCollection(collectionId);
    const liveItemIds = new Set(items.map((i) => i.id));
 
    const preferences = await this.scoringService.computePreferences(
      collectionId,
      curatorId,
      liveItemIds,
      events,
    );
 
    const orderRankByItem = this.deriveOrderRanks(items, preferences, events);
 
    // TWO statements instead of ~2N: clear FIRST so every order_rank is NULL, then
    // upsert the whole recomputed set in ONE statement. Clearing first is REQUIRED —
    // the partial unique index on (collection, curator, order_rank) is checked per
    // row within the upsert (not deferred), so permuting ranks in place could
    // transiently collide; nulling all then assigning DISTINCT ranks avoids it.
    // (This replaced a per-row awaited loop — P round-trips per decision — that
    // could blow the function timeout and roll back the whole tap.)
    await this.preferenceRepo.clearOrderRanks(collectionId, curatorId);
    await this.preferenceRepo.bulkUpsert(
      preferences.map((pref) => ({
        collectionId,
        curatorId,
        itemId: pref.itemId,
        score: pref.score,
        confidence: pref.confidence,
        rankBucket: pref.rankBucket,
        comparisonCount: pref.comparisonCount,
        orderRank: orderRankByItem.get(pref.itemId) ?? null,
      })),
    );
 
    return this.preferenceRepo.findByCollection(collectionId, curatorId);
  }
 
  // Run the pure binary-insertion engine over the curator's pairwise comparisons
  // for the comparable set (Love + Like), mapping each placed item to its dense slot (0 =
  // most loved). Items the engine leaves unplaced are absent from the map (=> null).
  // `items` is the collection's LIVE item set, already loaded by the caller (the
  // preference set is already filtered to it), so no second read is needed.
  private deriveOrderRanks(
    items: Item[],
    preferences: CollectionPreferenceItem[],
    events: DecisionEvent[],
  ): Map<string, number> {
    if (preferences.length === 0) return new Map();
 
    // Only the comparable set (Love + Like) — the same set `chooseSortPhase` reads.
    // Each item keeps its OWN createdAt (drives the engine's deterministic
    // canonical insertion sequence).
    const rankItems: RankItem[] = selectComparableItems(items, preferences)
      .map((i) => ({ id: i.id, createdAt: i.createdAt }));
 
    // Shared mapping with the live selector (chooseSortPhase) — both entry points
    // build ComparisonEvent[] identically, so they ask the same next question.
    const comparisons: ComparisonEvent[] = toComparisonEvents(events);
    const { order } = deriveOrder(rankItems, comparisons);
    return new Map(order.map((id, i) => [id, i]));
  }
 
  // ONE definition of "the curator's decision log" (spec §2.6): delegates to the
  // shared helper so the recompute and the round page (DecisionController
  // .listForCurator) provably read the same event set. Shared by bucket scoring
  // and the pairwise mapping.
  private async loadDecisionEvents(
    collectionId: string,
    curatorId: string,
  ): Promise<DecisionEvent[]> {
    return loadCuratorDecisionEvents(this.sessionRepo, this.decisionRepo, collectionId, curatorId);
  }
 
  async getItemPreferences(
    collectionId: string,
    curatorId: string,
  ): Promise<CollectionPreferenceItem[]> {
    return this.preferenceRepo.findByCollection(collectionId, curatorId);
  }
}