@pegma/support-desk-application 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import { auditRecordId, defineAudit } from "@pegma/audit";
2
2
  import { hasPermission } from "@pegma/authorization-core";
3
3
  import { defineMail, maxMailAttempts } from "@pegma/mail";
4
4
  import { noopLogger } from "@pegma/spine";
5
- import { defineCollection, MAX_SCAN_PAGE_SIZE, } from "@pegma/storage-core";
5
+ import { ConcurrencyError, defineCollection, MAX_SCAN_PAGE_SIZE, } from "@pegma/storage-core";
6
6
  import { applyTicketEvent, createTicket, effectiveTicketHandling, } from "@pegma/support-desk-core";
7
7
  import { assertAssistantTurnRecord, createAssistantTurnRecord, resolveAssistantTurnOptions, } from "./assistant-turn-records.js";
8
8
  import { createAssistantTurnServices, } from "./assistant-turns.js";
@@ -1068,12 +1068,34 @@ function enforcePartitionRecordLimit(recordCount, maximum, additional = 0) {
1068
1068
  export const assistantHumanReserveRows = 12;
1069
1069
  export const assistantHumanReserveMessages = 1;
1070
1070
  const firstHumanReplyReserveRows = 4;
1071
- async function planTicketDerivationMutation(records, before, after, rows, visibleChange = false, notice) {
1072
- const learning = await planResolutionLearningTicketMutation(records, before, after, notice !== undefined && visibleChange);
1073
- const engineering = notice === undefined
1074
- ? await planEngineeringTicketMutation(records, before, after, {
1071
+ /** Settle unused speculative failures without changing their consumer order. */
1072
+ async function prepareRead(read) {
1073
+ try {
1074
+ return { outcome: "read", value: await read() };
1075
+ }
1076
+ catch (error) {
1077
+ return { outcome: "failed", error };
1078
+ }
1079
+ }
1080
+ function readValue(observation) {
1081
+ if (observation.outcome === "failed")
1082
+ throw observation.error;
1083
+ return observation.value;
1084
+ }
1085
+ async function planTicketDerivationMutation(records, before, after, rows, visibleChange = false, notice, parallel = false) {
1086
+ const learningRead = planResolutionLearningTicketMutation(records, before, after, notice !== undefined && visibleChange);
1087
+ const engineeringRead = parallel && notice === undefined
1088
+ ? prepareRead(() => planEngineeringTicketMutation(records, before, after, {
1075
1089
  visibleChange,
1076
- })
1090
+ }))
1091
+ : undefined;
1092
+ const learning = await learningRead;
1093
+ const engineering = notice === undefined
1094
+ ? engineeringRead === undefined
1095
+ ? await planEngineeringTicketMutation(records, before, after, {
1096
+ visibleChange,
1097
+ })
1098
+ : readValue(await engineeringRead)
1077
1099
  : { actions: [], additionalRows: 0 };
1078
1100
  const reopened = ["resolved", "closed"].includes(before.value.ticket.status) &&
1079
1101
  !["resolved", "closed"].includes(after.status);
@@ -1499,41 +1521,8 @@ export async function projectTicketToQueue(ticketId, options) {
1499
1521
  }
1500
1522
  const records = store.collection(supportRecords);
1501
1523
  const projections = store.collection(queueIndex);
1502
- const ticketRecord = await records.get(ticketKey(ticketId));
1503
- if (ticketRecord?.kind !== "ticket") {
1504
- const existing = await projections.getVersioned(queueProjectionKey(ticketId));
1505
- if (existing === null) {
1506
- return "absent";
1507
- }
1508
- const removed = await projections.deleteIfUnchanged(queueProjectionKey(ticketId), existing.version);
1509
- return removed ? "deleted" : "kept";
1510
- }
1511
- const ticket = ticketRecord.ticket;
1512
- if (ticket.id !== ticketId) {
1513
- throw new TypeError("stored ticket id does not match its partition");
1514
- }
1515
- // Sample the host clock only for terminal retention. Active tickets never
1516
- // consult wall time, so post-commit projection does not consume sequence
1517
- // clocks used by ticket mutations.
1518
- if (isTerminalTicketStatus(ticket.status)) {
1519
- const now = clock.now();
1520
- if (ticketBeyondTerminalCutoff(ticket, now, terminalRetentionMilliseconds)) {
1521
- const existing = await projections.getVersioned(queueProjectionKey(ticketId));
1522
- if (existing === null) {
1523
- return "absent";
1524
- }
1525
- // Only prune a projection that still matches this terminal revision.
1526
- // A concurrent reopen can land a newer active row between the ticket
1527
- // read and this delete; that row must be preserved for repair to see.
1528
- if (existing.value.projectedRevision !== ticket.revision) {
1529
- return "kept";
1530
- }
1531
- const removed = await projections.deleteIfUnchanged(queueProjectionKey(ticketId), existing.version);
1532
- return removed ? "deleted" : "kept";
1533
- }
1534
- }
1535
- const derived = deriveQueueIndexRecord(ticket);
1536
- const result = await projections.update(queueProjectionKey(ticketId), (current) => {
1524
+ const key = queueProjectionKey(ticketId);
1525
+ function decide(current, derived) {
1537
1526
  if (current === null) {
1538
1527
  return { action: "write", value: derived };
1539
1528
  }
@@ -1560,8 +1549,55 @@ export async function projectTicketToQueue(ticketId, options) {
1560
1549
  if (queueIndexContentEqual(current, legacy))
1561
1550
  return { action: "write", value: derived };
1562
1551
  throw new SupportDeskConflictError("Queue projection revision collision with divergent content");
1563
- }, { maxAttempts: maxConflictAttempts });
1564
- return result.written ? "written" : "kept";
1552
+ }
1553
+ for (let attempt = 0; attempt < maxConflictAttempts; attempt += 1) {
1554
+ const ticketRead = records.get(ticketKey(ticketId));
1555
+ const projectionRead = prepareRead(() => projections.getVersioned(key));
1556
+ const ticketRecord = await ticketRead;
1557
+ if (ticketRecord?.kind !== "ticket") {
1558
+ const existing = readValue(await projectionRead);
1559
+ if (existing === null)
1560
+ return "absent";
1561
+ if (await projections.deleteIfUnchanged(key, existing.version))
1562
+ return "deleted";
1563
+ if (attempt + 1 === maxConflictAttempts)
1564
+ return "kept";
1565
+ continue;
1566
+ }
1567
+ const ticket = ticketRecord.ticket;
1568
+ if (ticket.id !== ticketId)
1569
+ throw new TypeError("stored ticket id does not match its partition");
1570
+ // Active tickets never consult wall time. Retry terminal retention only
1571
+ // with the new authoritative Ticket and projection observations together.
1572
+ if (isTerminalTicketStatus(ticket.status)) {
1573
+ const now = clock.now();
1574
+ if (ticketBeyondTerminalCutoff(ticket, now, terminalRetentionMilliseconds)) {
1575
+ const existing = readValue(await projectionRead);
1576
+ if (existing === null)
1577
+ return "absent";
1578
+ // A newer active projection from a concurrent reopen must survive.
1579
+ if (existing.value.projectedRevision !== ticket.revision)
1580
+ return "kept";
1581
+ if (await projections.deleteIfUnchanged(key, existing.version))
1582
+ return "deleted";
1583
+ if (attempt + 1 === maxConflictAttempts)
1584
+ return "kept";
1585
+ continue;
1586
+ }
1587
+ }
1588
+ const derived = deriveQueueIndexRecord(ticket);
1589
+ const existing = readValue(await projectionRead);
1590
+ const decision = decide(existing?.value ?? null, derived);
1591
+ if (decision.action !== "write")
1592
+ return "kept";
1593
+ const written = existing === null
1594
+ ? (await projections.insertIfAbsent(decision.value)).inserted
1595
+ : await projections.putIfUnchanged(decision.value, existing.version);
1596
+ if (written)
1597
+ return "written";
1598
+ // Rebuild both observations; never keep a stale Ticket across a CAS retry.
1599
+ }
1600
+ throw new ConcurrencyError(queueIndex.name, key, maxConflictAttempts);
1565
1601
  }
1566
1602
  function toStaffQueueItem(ticket) {
1567
1603
  return Object.freeze({
@@ -2625,13 +2661,21 @@ export function createSupportDeskApplication(options) {
2625
2661
  ? {}
2626
2662
  : { assistantTurnFingerprint: assistantTurnCommit.fingerprint }),
2627
2663
  });
2664
+ const completion = assistantTurnCommit;
2665
+ const parallel = completion?.kind === "complete";
2628
2666
  for (let attempt = 0; attempt < maxConflictAttempts; attempt += 1) {
2629
- const versioned = await records.getVersioned(ticketKey(command.ticketId));
2667
+ const ticketRead = records.getVersioned(ticketKey(command.ticketId));
2668
+ const duplicateRead = parallel
2669
+ ? prepareRead(() => records.get(commandKey(command.ticketId, command.commandId)))
2670
+ : undefined;
2671
+ const versioned = await ticketRead;
2630
2672
  if (versioned?.value.kind !== "ticket" ||
2631
2673
  (customer && !owns(versioned.value.ticket, access.principalId)))
2632
2674
  throw new SupportDeskNotFoundError();
2633
2675
  const ticket = versioned.value.ticket;
2634
- const duplicate = await records.get(commandKey(command.ticketId, command.commandId));
2676
+ const duplicate = duplicateRead === undefined
2677
+ ? await records.get(commandKey(command.ticketId, command.commandId))
2678
+ : readValue(await duplicateRead);
2635
2679
  if (duplicateMatches(duplicate, operation, command.messageId, fingerprint)) {
2636
2680
  if (duplicate?.kind !== "command" ||
2637
2681
  duplicate.resultTicket === undefined)
@@ -2640,6 +2684,27 @@ export function createSupportDeskApplication(options) {
2640
2684
  }
2641
2685
  if (duplicate !== null)
2642
2686
  throw new SupportDeskConflictError("Command ID reused with different input");
2687
+ if (parallel &&
2688
+ (ticket.channel !== "web" ||
2689
+ isTerminalTicketStatus(ticket.status) ||
2690
+ effectiveTicketHandling(ticket).state !== "assistant" ||
2691
+ ticket.revision !== command.expectedRevision ||
2692
+ effectiveTicketHandling(ticket).generation !==
2693
+ command.expectedHandlingGeneration)) {
2694
+ // The speculative receipt may predate a concurrent exact completion
2695
+ // that this Ticket observation already contains. Recover its receipt
2696
+ // before rejecting the now-stale live fence; ordinary paired reads
2697
+ // retain their existing cost and original whole-attempt CAS versions.
2698
+ const accepted = await records.get(commandKey(command.ticketId, command.commandId));
2699
+ if (duplicateMatches(accepted, operation, command.messageId, fingerprint)) {
2700
+ if (accepted?.kind !== "command" ||
2701
+ accepted.resultTicket === undefined)
2702
+ throw new SupportDeskConflictError("Handling receipt is incomplete");
2703
+ return accepted.resultTicket;
2704
+ }
2705
+ if (accepted !== null)
2706
+ throw new SupportDeskConflictError("Command ID reused with different input");
2707
+ }
2643
2708
  if (operation === "assistant_reply" && !assistantHandlingEnabled)
2644
2709
  throw new SupportDeskConflictError("Assistant handling is disabled");
2645
2710
  const rows = await records.list(command.ticketId);
@@ -2698,7 +2763,21 @@ export function createSupportDeskApplication(options) {
2698
2763
  });
2699
2764
  if (updated.revision === ticket.revision)
2700
2765
  return ticket;
2701
- const learningMutation = await planTicketDerivationMutation(records, { value: versioned.value, version: versioned.version }, updated, rows, operation === "assistant_reply");
2766
+ const originalTicket = {
2767
+ value: versioned.value,
2768
+ version: versioned.version,
2769
+ };
2770
+ const planMutation = () => planTicketDerivationMutation(records, originalTicket, updated, rows, operation === "assistant_reply", undefined, parallel);
2771
+ const mutationRead = parallel ? prepareRead(planMutation) : undefined;
2772
+ const quotaRead = parallel && operation === "assistant_reply"
2773
+ ? prepareRead(() => records.getVersioned(ticketQuotaKey(command.ticketId)))
2774
+ : undefined;
2775
+ const assistantRead = parallel && completion !== undefined
2776
+ ? prepareRead(() => assistantTurns.completionAction(ticket, rows, updated, completion))
2777
+ : undefined;
2778
+ const learningMutation = mutationRead === undefined
2779
+ ? await planMutation()
2780
+ : readValue(await mutationRead);
2702
2781
  const learningConfirmation = operation === "confirm_customer_success"
2703
2782
  ? await resolutionLearning.customerConfirmation(learningMutation.source, updated, access.principalId, command.lastSeenVisibleMessageId, command.commandId, rows)
2704
2783
  : { actions: [], additionalRows: 0 };
@@ -2714,7 +2793,9 @@ export function createSupportDeskApplication(options) {
2714
2793
  ...learningConfirmation.actions,
2715
2794
  ];
2716
2795
  if (operation === "assistant_reply") {
2717
- const quota = await records.getVersioned(ticketQuotaKey(command.ticketId));
2796
+ const quota = quotaRead === undefined
2797
+ ? await records.getVersioned(ticketQuotaKey(command.ticketId))
2798
+ : readValue(await quotaRead);
2718
2799
  if (quota?.value.kind !== "ticket_quota")
2719
2800
  throw new SupportDeskConflictError("Ticket partition quota metadata is missing");
2720
2801
  if (quota.value.messageCount >= limits.maxMessagesPerTicket)
@@ -2827,7 +2908,9 @@ export function createSupportDeskApplication(options) {
2827
2908
  });
2828
2909
  const assistantCommit = assistantTurnCommit === undefined
2829
2910
  ? undefined
2830
- : await assistantTurns.completionAction(ticket, rows, updated, assistantTurnCommit);
2911
+ : assistantRead === undefined
2912
+ ? await assistantTurns.completionAction(ticket, rows, updated, assistantTurnCommit)
2913
+ : readValue(await assistantRead);
2831
2914
  if (assistantCommit !== undefined) {
2832
2915
  actions.push(assistantCommit.action);
2833
2916
  await assistantCommit.verifyBeforeCommit();