payload-reserve 2.4.0 → 3.0.0

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.
Files changed (108) hide show
  1. package/README.md +217 -4
  2. package/dist/collections/Holds.d.ts +18 -0
  3. package/dist/collections/Holds.js +113 -0
  4. package/dist/collections/Holds.js.map +1 -0
  5. package/dist/collections/Reservations.js +8 -0
  6. package/dist/collections/Reservations.js.map +1 -1
  7. package/dist/collections/Resources.js +35 -1
  8. package/dist/collections/Resources.js.map +1 -1
  9. package/dist/collections/Services.js +41 -0
  10. package/dist/collections/Services.js.map +1 -1
  11. package/dist/components/CalendarView/LaneTimelineView.js +4 -5
  12. package/dist/components/CalendarView/LaneTimelineView.js.map +1 -1
  13. package/dist/components/CalendarView/index.js +55 -71
  14. package/dist/components/CalendarView/index.js.map +1 -1
  15. package/dist/components/DashboardWidget/DashboardWidgetServer.js +11 -74
  16. package/dist/components/DashboardWidget/DashboardWidgetServer.js.map +1 -1
  17. package/dist/components/DashboardWidget/fetchDashboardStats.d.ts +42 -0
  18. package/dist/components/DashboardWidget/fetchDashboardStats.js +113 -0
  19. package/dist/components/DashboardWidget/fetchDashboardStats.js.map +1 -0
  20. package/dist/defaults.d.ts +2 -0
  21. package/dist/defaults.js +11 -0
  22. package/dist/defaults.js.map +1 -1
  23. package/dist/endpoints/cancelBooking.js +43 -14
  24. package/dist/endpoints/cancelBooking.js.map +1 -1
  25. package/dist/endpoints/checkAvailability.js +12 -1
  26. package/dist/endpoints/checkAvailability.js.map +1 -1
  27. package/dist/endpoints/createBooking.js +108 -7
  28. package/dist/endpoints/createBooking.js.map +1 -1
  29. package/dist/endpoints/customerSearch.js +6 -0
  30. package/dist/endpoints/customerSearch.js.map +1 -1
  31. package/dist/endpoints/effectiveTimezone.js +1 -0
  32. package/dist/endpoints/effectiveTimezone.js.map +1 -1
  33. package/dist/endpoints/getSlots.js +12 -1
  34. package/dist/endpoints/getSlots.js.map +1 -1
  35. package/dist/endpoints/holdSlot.d.ts +12 -0
  36. package/dist/endpoints/holdSlot.js +126 -0
  37. package/dist/endpoints/holdSlot.js.map +1 -0
  38. package/dist/endpoints/releaseSlot.d.ts +8 -0
  39. package/dist/endpoints/releaseSlot.js +34 -0
  40. package/dist/endpoints/releaseSlot.js.map +1 -0
  41. package/dist/endpoints/resourceAvailability.d.ts +2 -0
  42. package/dist/endpoints/resourceAvailability.js +79 -3
  43. package/dist/endpoints/resourceAvailability.js.map +1 -1
  44. package/dist/hooks/holds/validateHoldSlot.d.ts +14 -0
  45. package/dist/hooks/holds/validateHoldSlot.js +90 -0
  46. package/dist/hooks/holds/validateHoldSlot.js.map +1 -0
  47. package/dist/hooks/reservations/acquireBookingLock.d.ts +30 -0
  48. package/dist/hooks/reservations/acquireBookingLock.js +71 -0
  49. package/dist/hooks/reservations/acquireBookingLock.js.map +1 -0
  50. package/dist/hooks/reservations/calculateEndTime.js +13 -1
  51. package/dist/hooks/reservations/calculateEndTime.js.map +1 -1
  52. package/dist/hooks/reservations/expandRequiredResources.js +3 -0
  53. package/dist/hooks/reservations/expandRequiredResources.js.map +1 -1
  54. package/dist/hooks/reservations/validateActive.d.ts +11 -0
  55. package/dist/hooks/reservations/validateActive.js +103 -0
  56. package/dist/hooks/reservations/validateActive.js.map +1 -0
  57. package/dist/hooks/reservations/validateConflicts.js +7 -1
  58. package/dist/hooks/reservations/validateConflicts.js.map +1 -1
  59. package/dist/hooks/reservations/validateGuestBooking.js +3 -0
  60. package/dist/hooks/reservations/validateGuestBooking.js.map +1 -1
  61. package/dist/hooks/shared/preventDeleteWhenReferenced.d.ts +42 -0
  62. package/dist/hooks/shared/preventDeleteWhenReferenced.js +87 -0
  63. package/dist/hooks/shared/preventDeleteWhenReferenced.js.map +1 -0
  64. package/dist/index.d.ts +1 -0
  65. package/dist/index.js.map +1 -1
  66. package/dist/plugin.js +179 -3
  67. package/dist/plugin.js.map +1 -1
  68. package/dist/services/AvailabilityService.d.ts +46 -5
  69. package/dist/services/AvailabilityService.js +168 -12
  70. package/dist/services/AvailabilityService.js.map +1 -1
  71. package/dist/services/HoldService.d.ts +53 -0
  72. package/dist/services/HoldService.js +169 -0
  73. package/dist/services/HoldService.js.map +1 -0
  74. package/dist/services/index.d.ts +1 -0
  75. package/dist/services/index.js.map +1 -1
  76. package/dist/translations/ar.json +5 -1
  77. package/dist/translations/de.json +5 -1
  78. package/dist/translations/en.json +5 -1
  79. package/dist/translations/es.json +5 -1
  80. package/dist/translations/fa.json +5 -1
  81. package/dist/translations/fr.json +5 -1
  82. package/dist/translations/hi.json +5 -1
  83. package/dist/translations/id.json +5 -1
  84. package/dist/translations/pl.json +5 -1
  85. package/dist/translations/ru.json +5 -1
  86. package/dist/translations/tr.json +5 -1
  87. package/dist/translations/zh.json +5 -1
  88. package/dist/types.d.ts +29 -0
  89. package/dist/types.js.map +1 -1
  90. package/dist/utilities/calendarGrid.d.ts +44 -0
  91. package/dist/utilities/calendarGrid.js +67 -0
  92. package/dist/utilities/calendarGrid.js.map +1 -0
  93. package/dist/utilities/reservationChanges.d.ts +6 -1
  94. package/dist/utilities/reservationChanges.js +1 -1
  95. package/dist/utilities/reservationChanges.js.map +1 -1
  96. package/dist/utilities/resolveReservationItems.d.ts +13 -2
  97. package/dist/utilities/resolveReservationItems.js +87 -2
  98. package/dist/utilities/resolveReservationItems.js.map +1 -1
  99. package/dist/utilities/retryOnWriteConflict.d.ts +66 -0
  100. package/dist/utilities/retryOnWriteConflict.js +135 -0
  101. package/dist/utilities/retryOnWriteConflict.js.map +1 -0
  102. package/dist/utilities/tenantTimezone.d.ts +77 -1
  103. package/dist/utilities/tenantTimezone.js +163 -2
  104. package/dist/utilities/tenantTimezone.js.map +1 -1
  105. package/dist/utilities/transactionSupport.d.ts +13 -0
  106. package/dist/utilities/transactionSupport.js +30 -0
  107. package/dist/utilities/transactionSupport.js.map +1 -0
  108. package/package.json +13 -13
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Weekday index for a calendar date, 0 = Sunday. TZ-independent pure calendar
3
+ * math — the UTC instant is a carrier for the date only, never a real time.
4
+ */
5
+ export declare function weekdayIndexOfDayKey(dayKey: string): number;
6
+ /** The Sunday on or before `dayKey`. */
7
+ export declare function startOfWeekDayKey(dayKey: string): string;
8
+ /** The Sunday on or before the 1st of `dayKey`'s month — the month grid origin. */
9
+ export declare function monthGridStartDayKey(dayKey: string): string;
10
+ /** `count` consecutive day keys starting at `startDayKey`. */
11
+ export declare function dayKeySequence(startDayKey: string, count: number): string[];
12
+ /**
13
+ * The instant at `hour`:00 on `dayKey` in the BUSINESS timezone.
14
+ *
15
+ * This is the whole point of the module: calendar rows are labelled with
16
+ * getHourInTimezone(date, businessTZ), so the instant a row maps to must be
17
+ * built in that same zone. Using Date#setHours here builds it in the VIEWER's
18
+ * zone, which is how clicking "10:00" came to book a different hour.
19
+ */
20
+ export declare function instantAtHour(dayKey: string, hour: number, timeZone: string): Date;
21
+ /**
22
+ * Like `instantAtHour`, but tolerates the exclusive end hour a grid window can
23
+ * carry: hour 24 is not a wall-clock time (`combineDayKeyAndTime` rejects
24
+ * "24:00"), it means midnight starting the FOLLOWING day, so carry it onto the
25
+ * next day key.
26
+ *
27
+ * The carry is deliberately a day-key increment rather than `+24h` on the day's
28
+ * start: on a DST-transition day the grid spans 23 or 25 real hours, and only
29
+ * re-resolving midnight in the business zone lands on the right instant.
30
+ */
31
+ export declare function gridInstant(dayKey: string, hour: number, timeZone: string): Date;
32
+ /**
33
+ * A Date safe to hand to Intl for rendering `dayKey`'s calendar date.
34
+ *
35
+ * Noon is deliberate: far enough from both midnights that formatting it back
36
+ * with the SAME `timeZone` it was built with can never render the adjacent
37
+ * date, DST included — which is how every caller in this codebase uses it
38
+ * (construct and format in the one business zone). The margin is only ±12h
39
+ * from that zone's own offset, not unconditional: formatting in a *different*
40
+ * zone whose offset differs from `timeZone`'s by more than ~12h (e.g. noon
41
+ * built in Pacific/Auckland, UTC+12/+13, formatted in a UTC-11 zone) can still
42
+ * land on the adjacent day.
43
+ */
44
+ export declare function displayDateForDayKey(dayKey: string, timeZone: string): Date;
@@ -0,0 +1,67 @@
1
+ import { addDaysToDayKey, combineDayKeyAndTime } from './timezoneUtils.js';
2
+ const DAY_KEY_RE = /^\d{4}-\d{2}-\d{2}$/;
3
+ function assertDayKey(dayKey) {
4
+ if (!DAY_KEY_RE.test(dayKey)) {
5
+ throw new Error(`Invalid day key "${dayKey}" — expected YYYY-MM-DD`);
6
+ }
7
+ }
8
+ /**
9
+ * Weekday index for a calendar date, 0 = Sunday. TZ-independent pure calendar
10
+ * math — the UTC instant is a carrier for the date only, never a real time.
11
+ */ export function weekdayIndexOfDayKey(dayKey) {
12
+ assertDayKey(dayKey);
13
+ return new Date(`${dayKey}T00:00:00Z`).getUTCDay();
14
+ }
15
+ /** The Sunday on or before `dayKey`. */ export function startOfWeekDayKey(dayKey) {
16
+ return addDaysToDayKey(dayKey, -weekdayIndexOfDayKey(dayKey));
17
+ }
18
+ /** The Sunday on or before the 1st of `dayKey`'s month — the month grid origin. */ export function monthGridStartDayKey(dayKey) {
19
+ assertDayKey(dayKey);
20
+ return startOfWeekDayKey(`${dayKey.slice(0, 8)}01`);
21
+ }
22
+ /** `count` consecutive day keys starting at `startDayKey`. */ export function dayKeySequence(startDayKey, count) {
23
+ assertDayKey(startDayKey);
24
+ const keys = [];
25
+ for(let i = 0; i < count; i++){
26
+ keys.push(addDaysToDayKey(startDayKey, i));
27
+ }
28
+ return keys;
29
+ }
30
+ /**
31
+ * The instant at `hour`:00 on `dayKey` in the BUSINESS timezone.
32
+ *
33
+ * This is the whole point of the module: calendar rows are labelled with
34
+ * getHourInTimezone(date, businessTZ), so the instant a row maps to must be
35
+ * built in that same zone. Using Date#setHours here builds it in the VIEWER's
36
+ * zone, which is how clicking "10:00" came to book a different hour.
37
+ */ export function instantAtHour(dayKey, hour, timeZone) {
38
+ return combineDayKeyAndTime(dayKey, `${String(hour).padStart(2, '0')}:00`, timeZone);
39
+ }
40
+ /**
41
+ * Like `instantAtHour`, but tolerates the exclusive end hour a grid window can
42
+ * carry: hour 24 is not a wall-clock time (`combineDayKeyAndTime` rejects
43
+ * "24:00"), it means midnight starting the FOLLOWING day, so carry it onto the
44
+ * next day key.
45
+ *
46
+ * The carry is deliberately a day-key increment rather than `+24h` on the day's
47
+ * start: on a DST-transition day the grid spans 23 or 25 real hours, and only
48
+ * re-resolving midnight in the business zone lands on the right instant.
49
+ */ export function gridInstant(dayKey, hour, timeZone) {
50
+ return instantAtHour(addDaysToDayKey(dayKey, Math.floor(hour / 24)), hour % 24, timeZone);
51
+ }
52
+ /**
53
+ * A Date safe to hand to Intl for rendering `dayKey`'s calendar date.
54
+ *
55
+ * Noon is deliberate: far enough from both midnights that formatting it back
56
+ * with the SAME `timeZone` it was built with can never render the adjacent
57
+ * date, DST included — which is how every caller in this codebase uses it
58
+ * (construct and format in the one business zone). The margin is only ±12h
59
+ * from that zone's own offset, not unconditional: formatting in a *different*
60
+ * zone whose offset differs from `timeZone`'s by more than ~12h (e.g. noon
61
+ * built in Pacific/Auckland, UTC+12/+13, formatted in a UTC-11 zone) can still
62
+ * land on the adjacent day.
63
+ */ export function displayDateForDayKey(dayKey, timeZone) {
64
+ return combineDayKeyAndTime(dayKey, '12:00', timeZone);
65
+ }
66
+
67
+ //# sourceMappingURL=calendarGrid.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utilities/calendarGrid.ts"],"sourcesContent":["import { addDaysToDayKey, combineDayKeyAndTime } from './timezoneUtils.js'\n\nconst DAY_KEY_RE = /^\\d{4}-\\d{2}-\\d{2}$/\n\nfunction assertDayKey(dayKey: string): void {\n if (!DAY_KEY_RE.test(dayKey)) {\n throw new Error(`Invalid day key \"${dayKey}\" — expected YYYY-MM-DD`)\n }\n}\n\n/**\n * Weekday index for a calendar date, 0 = Sunday. TZ-independent pure calendar\n * math — the UTC instant is a carrier for the date only, never a real time.\n */\nexport function weekdayIndexOfDayKey(dayKey: string): number {\n assertDayKey(dayKey)\n return new Date(`${dayKey}T00:00:00Z`).getUTCDay()\n}\n\n/** The Sunday on or before `dayKey`. */\nexport function startOfWeekDayKey(dayKey: string): string {\n return addDaysToDayKey(dayKey, -weekdayIndexOfDayKey(dayKey))\n}\n\n/** The Sunday on or before the 1st of `dayKey`'s month — the month grid origin. */\nexport function monthGridStartDayKey(dayKey: string): string {\n assertDayKey(dayKey)\n return startOfWeekDayKey(`${dayKey.slice(0, 8)}01`)\n}\n\n/** `count` consecutive day keys starting at `startDayKey`. */\nexport function dayKeySequence(startDayKey: string, count: number): string[] {\n assertDayKey(startDayKey)\n const keys: string[] = []\n for (let i = 0; i < count; i++) {\n keys.push(addDaysToDayKey(startDayKey, i))\n }\n return keys\n}\n\n/**\n * The instant at `hour`:00 on `dayKey` in the BUSINESS timezone.\n *\n * This is the whole point of the module: calendar rows are labelled with\n * getHourInTimezone(date, businessTZ), so the instant a row maps to must be\n * built in that same zone. Using Date#setHours here builds it in the VIEWER's\n * zone, which is how clicking \"10:00\" came to book a different hour.\n */\nexport function instantAtHour(dayKey: string, hour: number, timeZone: string): Date {\n return combineDayKeyAndTime(dayKey, `${String(hour).padStart(2, '0')}:00`, timeZone)\n}\n\n/**\n * Like `instantAtHour`, but tolerates the exclusive end hour a grid window can\n * carry: hour 24 is not a wall-clock time (`combineDayKeyAndTime` rejects\n * \"24:00\"), it means midnight starting the FOLLOWING day, so carry it onto the\n * next day key.\n *\n * The carry is deliberately a day-key increment rather than `+24h` on the day's\n * start: on a DST-transition day the grid spans 23 or 25 real hours, and only\n * re-resolving midnight in the business zone lands on the right instant.\n */\nexport function gridInstant(dayKey: string, hour: number, timeZone: string): Date {\n return instantAtHour(addDaysToDayKey(dayKey, Math.floor(hour / 24)), hour % 24, timeZone)\n}\n\n/**\n * A Date safe to hand to Intl for rendering `dayKey`'s calendar date.\n *\n * Noon is deliberate: far enough from both midnights that formatting it back\n * with the SAME `timeZone` it was built with can never render the adjacent\n * date, DST included — which is how every caller in this codebase uses it\n * (construct and format in the one business zone). The margin is only ±12h\n * from that zone's own offset, not unconditional: formatting in a *different*\n * zone whose offset differs from `timeZone`'s by more than ~12h (e.g. noon\n * built in Pacific/Auckland, UTC+12/+13, formatted in a UTC-11 zone) can still\n * land on the adjacent day.\n */\nexport function displayDateForDayKey(dayKey: string, timeZone: string): Date {\n return combineDayKeyAndTime(dayKey, '12:00', timeZone)\n}\n"],"names":["addDaysToDayKey","combineDayKeyAndTime","DAY_KEY_RE","assertDayKey","dayKey","test","Error","weekdayIndexOfDayKey","Date","getUTCDay","startOfWeekDayKey","monthGridStartDayKey","slice","dayKeySequence","startDayKey","count","keys","i","push","instantAtHour","hour","timeZone","String","padStart","gridInstant","Math","floor","displayDateForDayKey"],"mappings":"AAAA,SAASA,eAAe,EAAEC,oBAAoB,QAAQ,qBAAoB;AAE1E,MAAMC,aAAa;AAEnB,SAASC,aAAaC,MAAc;IAClC,IAAI,CAACF,WAAWG,IAAI,CAACD,SAAS;QAC5B,MAAM,IAAIE,MAAM,CAAC,iBAAiB,EAAEF,OAAO,uBAAuB,CAAC;IACrE;AACF;AAEA;;;CAGC,GACD,OAAO,SAASG,qBAAqBH,MAAc;IACjDD,aAAaC;IACb,OAAO,IAAII,KAAK,GAAGJ,OAAO,UAAU,CAAC,EAAEK,SAAS;AAClD;AAEA,sCAAsC,GACtC,OAAO,SAASC,kBAAkBN,MAAc;IAC9C,OAAOJ,gBAAgBI,QAAQ,CAACG,qBAAqBH;AACvD;AAEA,iFAAiF,GACjF,OAAO,SAASO,qBAAqBP,MAAc;IACjDD,aAAaC;IACb,OAAOM,kBAAkB,GAAGN,OAAOQ,KAAK,CAAC,GAAG,GAAG,EAAE,CAAC;AACpD;AAEA,4DAA4D,GAC5D,OAAO,SAASC,eAAeC,WAAmB,EAAEC,KAAa;IAC/DZ,aAAaW;IACb,MAAME,OAAiB,EAAE;IACzB,IAAK,IAAIC,IAAI,GAAGA,IAAIF,OAAOE,IAAK;QAC9BD,KAAKE,IAAI,CAAClB,gBAAgBc,aAAaG;IACzC;IACA,OAAOD;AACT;AAEA;;;;;;;CAOC,GACD,OAAO,SAASG,cAAcf,MAAc,EAAEgB,IAAY,EAAEC,QAAgB;IAC1E,OAAOpB,qBAAqBG,QAAQ,GAAGkB,OAAOF,MAAMG,QAAQ,CAAC,GAAG,KAAK,GAAG,CAAC,EAAEF;AAC7E;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASG,YAAYpB,MAAc,EAAEgB,IAAY,EAAEC,QAAgB;IACxE,OAAOF,cAAcnB,gBAAgBI,QAAQqB,KAAKC,KAAK,CAACN,OAAO,MAAMA,OAAO,IAAIC;AAClF;AAEA;;;;;;;;;;;CAWC,GACD,OAAO,SAASM,qBAAqBvB,MAAc,EAAEiB,QAAgB;IACnE,OAAOpB,qBAAqBG,QAAQ,SAASiB;AAC/C"}
@@ -11,7 +11,12 @@ export declare function mergeReservationData(data: Record<string, unknown>, orig
11
11
  * field, and a notes-only edit must not trigger re-validation.
12
12
  */
13
13
  export declare function schedulingFieldsChanged({ blockingStatuses, data, originalDoc, }: {
14
- blockingStatuses: string[];
14
+ /**
15
+ * Omit to compare scheduling VALUES only, skipping the status clause below.
16
+ * `validateActive` needs that: a confirm or cancel must stay possible on a
17
+ * booking whose service or resource was deactivated after it was made.
18
+ */
19
+ blockingStatuses?: string[];
15
20
  data: Record<string, unknown>;
16
21
  originalDoc: Record<string, unknown> | undefined;
17
22
  }): boolean;
@@ -76,7 +76,7 @@ function itemsEqual(a, b) {
76
76
  return true;
77
77
  }
78
78
  }
79
- if ('status' in data && typeof data.status === 'string') {
79
+ if (blockingStatuses && 'status' in data && typeof data.status === 'string') {
80
80
  const prevStatus = originalDoc.status;
81
81
  if (data.status !== prevStatus && blockingStatuses.includes(data.status) && (prevStatus === undefined || !blockingStatuses.includes(prevStatus))) {
82
82
  return true;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utilities/reservationChanges.ts"],"sourcesContent":["import { extractId } from './resolveReservationItems.js'\n\n/**\n * Fields whose change means a reservation's slot occupancy may differ,\n * requiring conflict re-validation and endTime recomputation on update.\n */\nconst SCHEDULING_FIELDS = [\n 'endTime',\n 'guestCount',\n 'items',\n 'resource',\n 'service',\n 'startTime',\n] as const\n\n/**\n * Read-only merged view of an update: the original document overlaid with the\n * incoming partial patch. Used for validation only — never assign the result\n * back into `data`, or unchanged fields get written back to the database.\n */\nexport function mergeReservationData(\n data: Record<string, unknown>,\n originalDoc: Record<string, unknown> | undefined,\n): Record<string, unknown> {\n return { ...(originalDoc ?? {}), ...(data ?? {}) }\n}\n\nfunction normalizeDate(value: unknown): null | number {\n if (value === null || value === undefined || value === '') {\n return null\n }\n const time = new Date(value as string).getTime()\n return Number.isNaN(time) ? null : time\n}\n\nfunction normalizeRelationship(value: unknown): null | string {\n const id = extractId(value)\n return id === undefined ? null : String(id)\n}\n\nfunction itemsEqual(a: unknown, b: unknown): boolean {\n const listA = Array.isArray(a) ? (a as Array<Record<string, unknown>>) : []\n const listB = Array.isArray(b) ? (b as Array<Record<string, unknown>>) : []\n if (listA.length !== listB.length) {\n return false\n }\n return listA.every((itemA, i) => {\n const itemB = listB[i]\n return (\n normalizeRelationship(itemA.resource) === normalizeRelationship(itemB.resource) &&\n normalizeRelationship(itemA.service) === normalizeRelationship(itemB.service) &&\n normalizeDate(itemA.startTime) === normalizeDate(itemB.startTime) &&\n normalizeDate(itemA.endTime) === normalizeDate(itemB.endTime) &&\n ((itemA.guestCount ?? null) as null | number) ===\n ((itemB.guestCount ?? null) as null | number)\n )\n })\n}\n\n/**\n * True when the update patch changes any scheduling-relevant field, or moves\n * status from a non-blocking value into a blocking one (re-occupying a slot).\n * Key presence alone is not a change — full-document admin saves include every\n * field, and a notes-only edit must not trigger re-validation.\n */\nexport function schedulingFieldsChanged({\n blockingStatuses,\n data,\n originalDoc,\n}: {\n blockingStatuses: string[]\n data: Record<string, unknown>\n originalDoc: Record<string, unknown> | undefined\n}): boolean {\n if (!originalDoc) {\n return true\n }\n\n for (const field of SCHEDULING_FIELDS) {\n if (!(field in data)) {\n continue\n }\n const next = data[field]\n const prev = originalDoc[field]\n let changed: boolean\n switch (field) {\n case 'endTime':\n case 'startTime':\n changed = normalizeDate(next) !== normalizeDate(prev)\n break\n case 'guestCount':\n changed = ((next ?? null) as null | number) !== ((prev ?? null) as null | number)\n break\n case 'items':\n changed = !itemsEqual(next, prev)\n break\n default:\n changed = normalizeRelationship(next) !== normalizeRelationship(prev)\n }\n if (changed) {\n return true\n }\n }\n\n if ('status' in data && typeof data.status === 'string') {\n const prevStatus = originalDoc.status as string | undefined\n if (\n data.status !== prevStatus &&\n blockingStatuses.includes(data.status) &&\n (prevStatus === undefined || !blockingStatuses.includes(prevStatus))\n ) {\n return true\n }\n }\n\n return false\n}\n"],"names":["extractId","SCHEDULING_FIELDS","mergeReservationData","data","originalDoc","normalizeDate","value","undefined","time","Date","getTime","Number","isNaN","normalizeRelationship","id","String","itemsEqual","a","b","listA","Array","isArray","listB","length","every","itemA","i","itemB","resource","service","startTime","endTime","guestCount","schedulingFieldsChanged","blockingStatuses","field","next","prev","changed","status","prevStatus","includes"],"mappings":"AAAA,SAASA,SAAS,QAAQ,+BAA8B;AAExD;;;CAGC,GACD,MAAMC,oBAAoB;IACxB;IACA;IACA;IACA;IACA;IACA;CACD;AAED;;;;CAIC,GACD,OAAO,SAASC,qBACdC,IAA6B,EAC7BC,WAAgD;IAEhD,OAAO;QAAE,GAAIA,eAAe,CAAC,CAAC;QAAG,GAAID,QAAQ,CAAC,CAAC;IAAE;AACnD;AAEA,SAASE,cAAcC,KAAc;IACnC,IAAIA,UAAU,QAAQA,UAAUC,aAAaD,UAAU,IAAI;QACzD,OAAO;IACT;IACA,MAAME,OAAO,IAAIC,KAAKH,OAAiBI,OAAO;IAC9C,OAAOC,OAAOC,KAAK,CAACJ,QAAQ,OAAOA;AACrC;AAEA,SAASK,sBAAsBP,KAAc;IAC3C,MAAMQ,KAAKd,UAAUM;IACrB,OAAOQ,OAAOP,YAAY,OAAOQ,OAAOD;AAC1C;AAEA,SAASE,WAAWC,CAAU,EAAEC,CAAU;IACxC,MAAMC,QAAQC,MAAMC,OAAO,CAACJ,KAAMA,IAAuC,EAAE;IAC3E,MAAMK,QAAQF,MAAMC,OAAO,CAACH,KAAMA,IAAuC,EAAE;IAC3E,IAAIC,MAAMI,MAAM,KAAKD,MAAMC,MAAM,EAAE;QACjC,OAAO;IACT;IACA,OAAOJ,MAAMK,KAAK,CAAC,CAACC,OAAOC;QACzB,MAAMC,QAAQL,KAAK,CAACI,EAAE;QACtB,OACEb,sBAAsBY,MAAMG,QAAQ,MAAMf,sBAAsBc,MAAMC,QAAQ,KAC9Ef,sBAAsBY,MAAMI,OAAO,MAAMhB,sBAAsBc,MAAME,OAAO,KAC5ExB,cAAcoB,MAAMK,SAAS,MAAMzB,cAAcsB,MAAMG,SAAS,KAChEzB,cAAcoB,MAAMM,OAAO,MAAM1B,cAAcsB,MAAMI,OAAO,KAC5D,AAAEN,CAAAA,MAAMO,UAAU,IAAI,IAAG,MACrBL,CAAAA,MAAMK,UAAU,IAAI,IAAG;IAE/B;AACF;AAEA;;;;;CAKC,GACD,OAAO,SAASC,wBAAwB,EACtCC,gBAAgB,EAChB/B,IAAI,EACJC,WAAW,EAKZ;IACC,IAAI,CAACA,aAAa;QAChB,OAAO;IACT;IAEA,KAAK,MAAM+B,SAASlC,kBAAmB;QACrC,IAAI,CAAEkC,CAAAA,SAAShC,IAAG,GAAI;YACpB;QACF;QACA,MAAMiC,OAAOjC,IAAI,CAACgC,MAAM;QACxB,MAAME,OAAOjC,WAAW,CAAC+B,MAAM;QAC/B,IAAIG;QACJ,OAAQH;YACN,KAAK;YACL,KAAK;gBACHG,UAAUjC,cAAc+B,UAAU/B,cAAcgC;gBAChD;YACF,KAAK;gBACHC,UAAU,AAAEF,CAAAA,QAAQ,IAAG,MAA2BC,CAAAA,QAAQ,IAAG;gBAC7D;YACF,KAAK;gBACHC,UAAU,CAACtB,WAAWoB,MAAMC;gBAC5B;YACF;gBACEC,UAAUzB,sBAAsBuB,UAAUvB,sBAAsBwB;QACpE;QACA,IAAIC,SAAS;YACX,OAAO;QACT;IACF;IAEA,IAAI,YAAYnC,QAAQ,OAAOA,KAAKoC,MAAM,KAAK,UAAU;QACvD,MAAMC,aAAapC,YAAYmC,MAAM;QACrC,IACEpC,KAAKoC,MAAM,KAAKC,cAChBN,iBAAiBO,QAAQ,CAACtC,KAAKoC,MAAM,KACpCC,CAAAA,eAAejC,aAAa,CAAC2B,iBAAiBO,QAAQ,CAACD,WAAU,GAClE;YACA,OAAO;QACT;IACF;IAEA,OAAO;AACT"}
1
+ {"version":3,"sources":["../../src/utilities/reservationChanges.ts"],"sourcesContent":["import { extractId } from './resolveReservationItems.js'\n\n/**\n * Fields whose change means a reservation's slot occupancy may differ,\n * requiring conflict re-validation and endTime recomputation on update.\n */\nconst SCHEDULING_FIELDS = [\n 'endTime',\n 'guestCount',\n 'items',\n 'resource',\n 'service',\n 'startTime',\n] as const\n\n/**\n * Read-only merged view of an update: the original document overlaid with the\n * incoming partial patch. Used for validation only — never assign the result\n * back into `data`, or unchanged fields get written back to the database.\n */\nexport function mergeReservationData(\n data: Record<string, unknown>,\n originalDoc: Record<string, unknown> | undefined,\n): Record<string, unknown> {\n return { ...(originalDoc ?? {}), ...(data ?? {}) }\n}\n\nfunction normalizeDate(value: unknown): null | number {\n if (value === null || value === undefined || value === '') {\n return null\n }\n const time = new Date(value as string).getTime()\n return Number.isNaN(time) ? null : time\n}\n\nfunction normalizeRelationship(value: unknown): null | string {\n const id = extractId(value)\n return id === undefined ? null : String(id)\n}\n\nfunction itemsEqual(a: unknown, b: unknown): boolean {\n const listA = Array.isArray(a) ? (a as Array<Record<string, unknown>>) : []\n const listB = Array.isArray(b) ? (b as Array<Record<string, unknown>>) : []\n if (listA.length !== listB.length) {\n return false\n }\n return listA.every((itemA, i) => {\n const itemB = listB[i]\n return (\n normalizeRelationship(itemA.resource) === normalizeRelationship(itemB.resource) &&\n normalizeRelationship(itemA.service) === normalizeRelationship(itemB.service) &&\n normalizeDate(itemA.startTime) === normalizeDate(itemB.startTime) &&\n normalizeDate(itemA.endTime) === normalizeDate(itemB.endTime) &&\n ((itemA.guestCount ?? null) as null | number) ===\n ((itemB.guestCount ?? null) as null | number)\n )\n })\n}\n\n/**\n * True when the update patch changes any scheduling-relevant field, or moves\n * status from a non-blocking value into a blocking one (re-occupying a slot).\n * Key presence alone is not a change — full-document admin saves include every\n * field, and a notes-only edit must not trigger re-validation.\n */\nexport function schedulingFieldsChanged({\n blockingStatuses,\n data,\n originalDoc,\n}: {\n /**\n * Omit to compare scheduling VALUES only, skipping the status clause below.\n * `validateActive` needs that: a confirm or cancel must stay possible on a\n * booking whose service or resource was deactivated after it was made.\n */\n blockingStatuses?: string[]\n data: Record<string, unknown>\n originalDoc: Record<string, unknown> | undefined\n}): boolean {\n if (!originalDoc) {\n return true\n }\n\n for (const field of SCHEDULING_FIELDS) {\n if (!(field in data)) {\n continue\n }\n const next = data[field]\n const prev = originalDoc[field]\n let changed: boolean\n switch (field) {\n case 'endTime':\n case 'startTime':\n changed = normalizeDate(next) !== normalizeDate(prev)\n break\n case 'guestCount':\n changed = ((next ?? null) as null | number) !== ((prev ?? null) as null | number)\n break\n case 'items':\n changed = !itemsEqual(next, prev)\n break\n default:\n changed = normalizeRelationship(next) !== normalizeRelationship(prev)\n }\n if (changed) {\n return true\n }\n }\n\n if (blockingStatuses && 'status' in data && typeof data.status === 'string') {\n const prevStatus = originalDoc.status as string | undefined\n if (\n data.status !== prevStatus &&\n blockingStatuses.includes(data.status) &&\n (prevStatus === undefined || !blockingStatuses.includes(prevStatus))\n ) {\n return true\n }\n }\n\n return false\n}\n"],"names":["extractId","SCHEDULING_FIELDS","mergeReservationData","data","originalDoc","normalizeDate","value","undefined","time","Date","getTime","Number","isNaN","normalizeRelationship","id","String","itemsEqual","a","b","listA","Array","isArray","listB","length","every","itemA","i","itemB","resource","service","startTime","endTime","guestCount","schedulingFieldsChanged","blockingStatuses","field","next","prev","changed","status","prevStatus","includes"],"mappings":"AAAA,SAASA,SAAS,QAAQ,+BAA8B;AAExD;;;CAGC,GACD,MAAMC,oBAAoB;IACxB;IACA;IACA;IACA;IACA;IACA;CACD;AAED;;;;CAIC,GACD,OAAO,SAASC,qBACdC,IAA6B,EAC7BC,WAAgD;IAEhD,OAAO;QAAE,GAAIA,eAAe,CAAC,CAAC;QAAG,GAAID,QAAQ,CAAC,CAAC;IAAE;AACnD;AAEA,SAASE,cAAcC,KAAc;IACnC,IAAIA,UAAU,QAAQA,UAAUC,aAAaD,UAAU,IAAI;QACzD,OAAO;IACT;IACA,MAAME,OAAO,IAAIC,KAAKH,OAAiBI,OAAO;IAC9C,OAAOC,OAAOC,KAAK,CAACJ,QAAQ,OAAOA;AACrC;AAEA,SAASK,sBAAsBP,KAAc;IAC3C,MAAMQ,KAAKd,UAAUM;IACrB,OAAOQ,OAAOP,YAAY,OAAOQ,OAAOD;AAC1C;AAEA,SAASE,WAAWC,CAAU,EAAEC,CAAU;IACxC,MAAMC,QAAQC,MAAMC,OAAO,CAACJ,KAAMA,IAAuC,EAAE;IAC3E,MAAMK,QAAQF,MAAMC,OAAO,CAACH,KAAMA,IAAuC,EAAE;IAC3E,IAAIC,MAAMI,MAAM,KAAKD,MAAMC,MAAM,EAAE;QACjC,OAAO;IACT;IACA,OAAOJ,MAAMK,KAAK,CAAC,CAACC,OAAOC;QACzB,MAAMC,QAAQL,KAAK,CAACI,EAAE;QACtB,OACEb,sBAAsBY,MAAMG,QAAQ,MAAMf,sBAAsBc,MAAMC,QAAQ,KAC9Ef,sBAAsBY,MAAMI,OAAO,MAAMhB,sBAAsBc,MAAME,OAAO,KAC5ExB,cAAcoB,MAAMK,SAAS,MAAMzB,cAAcsB,MAAMG,SAAS,KAChEzB,cAAcoB,MAAMM,OAAO,MAAM1B,cAAcsB,MAAMI,OAAO,KAC5D,AAAEN,CAAAA,MAAMO,UAAU,IAAI,IAAG,MACrBL,CAAAA,MAAMK,UAAU,IAAI,IAAG;IAE/B;AACF;AAEA;;;;;CAKC,GACD,OAAO,SAASC,wBAAwB,EACtCC,gBAAgB,EAChB/B,IAAI,EACJC,WAAW,EAUZ;IACC,IAAI,CAACA,aAAa;QAChB,OAAO;IACT;IAEA,KAAK,MAAM+B,SAASlC,kBAAmB;QACrC,IAAI,CAAEkC,CAAAA,SAAShC,IAAG,GAAI;YACpB;QACF;QACA,MAAMiC,OAAOjC,IAAI,CAACgC,MAAM;QACxB,MAAME,OAAOjC,WAAW,CAAC+B,MAAM;QAC/B,IAAIG;QACJ,OAAQH;YACN,KAAK;YACL,KAAK;gBACHG,UAAUjC,cAAc+B,UAAU/B,cAAcgC;gBAChD;YACF,KAAK;gBACHC,UAAU,AAAEF,CAAAA,QAAQ,IAAG,MAA2BC,CAAAA,QAAQ,IAAG;gBAC7D;YACF,KAAK;gBACHC,UAAU,CAACtB,WAAWoB,MAAMC;gBAC5B;YACF;gBACEC,UAAUzB,sBAAsBuB,UAAUvB,sBAAsBwB;QACpE;QACA,IAAIC,SAAS;YACX,OAAO;QACT;IACF;IAEA,IAAIJ,oBAAoB,YAAY/B,QAAQ,OAAOA,KAAKoC,MAAM,KAAK,UAAU;QAC3E,MAAMC,aAAapC,YAAYmC,MAAM;QACrC,IACEpC,KAAKoC,MAAM,KAAKC,cAChBN,iBAAiBO,QAAQ,CAACtC,KAAKoC,MAAM,KACpCC,CAAAA,eAAejC,aAAa,CAAC2B,iBAAiBO,QAAQ,CAACD,WAAU,GAClE;YACA,OAAO;QACT;IACF;IAEA,OAAO;AACT"}
@@ -1,5 +1,7 @@
1
1
  export type ResolvedItem = {
2
2
  endTime: string;
3
+ /** True when this item was synthesised from the top-level resource/startTime. */
4
+ fromParent?: boolean;
3
5
  guestCount: number;
4
6
  resource: number | string;
5
7
  service?: number | string;
@@ -8,13 +10,22 @@ export type ResolvedItem = {
8
10
  /**
9
11
  * Normalize reservation data into a list of resource-level items.
10
12
  *
11
- * - If items[] is populated -> return items (filling defaults from parent).
13
+ * - If items[] is populated -> return items (filling defaults from parent), then
14
+ * append a synthesised item for the top-level resource/startTime/endTime UNLESS
15
+ * an items[] entry can already be shown to cover that same window (see B1 in
16
+ * the synthesis step below) — this is what makes the top-level `resource`
17
+ * conflict-checked even when items[] never names it.
12
18
  * Items missing startTime or resource throw a ValidationError.
13
19
  * Duplicate (resource, startTime) pairs throw a ValidationError.
20
+ * An inverted top-level (parent) window throws a ValidationError too — UNLESS
21
+ * `options.lenient` is set, in which case parent synthesis is silently
22
+ * skipped instead (see the lenient-mode note below).
14
23
  * - If items[] is empty/absent -> return single item from top-level fields
15
24
  *
16
25
  * Every downstream function (conflict check, endTime calc, availability)
17
26
  * works with ResolvedItem[], never with raw reservation data.
18
27
  */
19
- export declare function resolveReservationItems(data: Record<string, unknown>): ResolvedItem[];
28
+ export declare function resolveReservationItems(data: Record<string, unknown>, options?: {
29
+ lenient?: boolean;
30
+ }): ResolvedItem[];
20
31
  export declare function extractId(value: unknown): number | string | undefined;
@@ -1,15 +1,24 @@
1
1
  import { ValidationError } from 'payload';
2
+ import { doRangesOverlap } from './slotUtils.js';
2
3
  /**
3
4
  * Normalize reservation data into a list of resource-level items.
4
5
  *
5
- * - If items[] is populated -> return items (filling defaults from parent).
6
+ * - If items[] is populated -> return items (filling defaults from parent), then
7
+ * append a synthesised item for the top-level resource/startTime/endTime UNLESS
8
+ * an items[] entry can already be shown to cover that same window (see B1 in
9
+ * the synthesis step below) — this is what makes the top-level `resource`
10
+ * conflict-checked even when items[] never names it.
6
11
  * Items missing startTime or resource throw a ValidationError.
7
12
  * Duplicate (resource, startTime) pairs throw a ValidationError.
13
+ * An inverted top-level (parent) window throws a ValidationError too — UNLESS
14
+ * `options.lenient` is set, in which case parent synthesis is silently
15
+ * skipped instead (see the lenient-mode note below).
8
16
  * - If items[] is empty/absent -> return single item from top-level fields
9
17
  *
10
18
  * Every downstream function (conflict check, endTime calc, availability)
11
19
  * works with ResolvedItem[], never with raw reservation data.
12
- */ export function resolveReservationItems(data) {
20
+ */ export function resolveReservationItems(data, options) {
21
+ const lenient = options?.lenient ?? false;
13
22
  const items = data.items;
14
23
  if (items && items.length > 0) {
15
24
  const resolved = [];
@@ -58,6 +67,82 @@ import { ValidationError } from 'payload';
58
67
  startTime
59
68
  });
60
69
  }
70
+ // The stored row occupies its top-level `resource` for every OTHER booking's
71
+ // conflict check (buildCoarseOverlapQuery matches top level OR items[]), so
72
+ // it must occupy it for its own check too. Sharing a resource id with an
73
+ // items[] entry is NOT sufficient to skip synthesis — an items[] entry for
74
+ // the same resource at an unrelated time is a genuinely separate occupancy,
75
+ // and treating "same resource anywhere" as "already covered" reopens the
76
+ // exact double-booking class this function exists to close (a caller can
77
+ // list resource A at one time in items[] while the top-level fields book A
78
+ // at a completely different, uncovered time). Synthesis is skipped only
79
+ // when an items[] entry for the SAME resource can be shown to cover the
80
+ // SAME window:
81
+ // (a) an exact startTime match — needs no endTime, so it still works when
82
+ // this function runs before endTime is computed (validateActive and
83
+ // calculateEndTime both call it before calculateEndTime has run); or
84
+ // (b) both endTimes are known and the windows overlap — covers
85
+ // calculateEndTime's multi-resource branch, which can overwrite the
86
+ // top-level startTime/endTime to SPAN every item, so by the time this
87
+ // function runs again (e.g. from validateConflicts) the parent's
88
+ // window no longer starts at the same instant as any one item even
89
+ // though that item's own window is fully contained in it.
90
+ // When neither can be shown, synthesize: a redundant-but-harmless extra
91
+ // check is far cheaper than a silently missed one.
92
+ const parentResource = extractId(data.resource);
93
+ const parentStart = data.startTime;
94
+ const parentEnd = data.endTime;
95
+ // An inverted parent window can never overlap anything, so the coverage test
96
+ // below would always say "not covered" and synthesise a phantom item that
97
+ // conflicts with nothing. Reject it at the source instead — it is malformed
98
+ // input, not a case to paper over.
99
+ //
100
+ // Lenient mode (reservationOccupancies only — see AvailabilityService.ts)
101
+ // resolves ALREADY-STORED documents, which can carry an inverted window
102
+ // from a context.skipReservationHooks write or data predating this check.
103
+ // A read must never crash over one malformed row, and — critically — it
104
+ // must not lose the real items[] occupancies already resolved above by
105
+ // throwing out of this function entirely. So lenient mode just skips
106
+ // parent synthesis here instead of throwing: precisely the pre-check
107
+ // behavior minus the (harmless but pointless) phantom item. The write
108
+ // path never passes `lenient`, so it keeps the hard rejection.
109
+ if (parentStart && parentEnd && new Date(parentEnd) <= new Date(parentStart)) {
110
+ if (lenient) {
111
+ return resolved;
112
+ }
113
+ throw new ValidationError({
114
+ errors: [
115
+ {
116
+ message: 'endTime must be after startTime',
117
+ path: 'endTime'
118
+ }
119
+ ]
120
+ });
121
+ }
122
+ if (parentResource !== undefined && parentResource !== '' && parentStart) {
123
+ const parentAlreadyItemized = resolved.some((item)=>{
124
+ // String-compare ids: a raw id (string for Mongo, number for Postgres)
125
+ // and a populated relationship's extracted `.id` should match even if
126
+ // one side came through as a different primitive type than the other.
127
+ if (String(item.resource) !== String(parentResource)) {
128
+ return false;
129
+ }
130
+ if (item.startTime === parentStart) {
131
+ return true;
132
+ }
133
+ return Boolean(parentEnd && item.endTime && doRangesOverlap(new Date(parentStart), new Date(parentEnd), new Date(item.startTime), new Date(item.endTime)));
134
+ });
135
+ if (!parentAlreadyItemized) {
136
+ resolved.push({
137
+ endTime: parentEnd,
138
+ fromParent: true,
139
+ guestCount: data.guestCount ?? 1,
140
+ resource: parentResource,
141
+ service: extractId(data.service),
142
+ startTime: parentStart
143
+ });
144
+ }
145
+ }
61
146
  return resolved;
62
147
  }
63
148
  // Single-resource fallback (current behavior)
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utilities/resolveReservationItems.ts"],"sourcesContent":["import { ValidationError } from 'payload'\n\nexport type ResolvedItem = {\n endTime: string\n guestCount: number\n resource: number | string\n service?: number | string\n startTime: string\n}\n\n/**\n * Normalize reservation data into a list of resource-level items.\n *\n * - If items[] is populated -> return items (filling defaults from parent).\n * Items missing startTime or resource throw a ValidationError.\n * Duplicate (resource, startTime) pairs throw a ValidationError.\n * - If items[] is empty/absent -> return single item from top-level fields\n *\n * Every downstream function (conflict check, endTime calc, availability)\n * works with ResolvedItem[], never with raw reservation data.\n */\nexport function resolveReservationItems(data: Record<string, unknown>): ResolvedItem[] {\n const items = data.items as Array<Record<string, unknown>> | undefined\n\n if (items && items.length > 0) {\n const resolved: ResolvedItem[] = []\n const seen = new Set<string>()\n\n for (let i = 0; i < items.length; i++) {\n const item = items[i]\n const resource = extractId(item.resource) ?? extractId(data.resource)\n const startTime = (item.startTime as string) ?? (data.startTime as string)\n\n if (resource === undefined || resource === '') {\n throw new ValidationError({\n errors: [\n {\n message: `Item ${i} is missing a resource`,\n path: `items.${i}.resource`,\n },\n ],\n })\n }\n\n if (!startTime) {\n throw new ValidationError({\n errors: [\n {\n message: `Item ${i} is missing a startTime`,\n path: `items.${i}.startTime`,\n },\n ],\n })\n }\n\n const key = `${resource}::${startTime}`\n if (seen.has(key)) {\n throw new ValidationError({\n errors: [\n {\n message: `Duplicate booking: item ${i} has the same resource and startTime as a previous item`,\n path: `items.${i}.startTime`,\n },\n ],\n })\n }\n seen.add(key)\n\n resolved.push({\n endTime: (item.endTime as string) ?? (data.endTime as string),\n guestCount: (item.guestCount as number) ?? (data.guestCount as number) ?? 1,\n resource,\n service: extractId(item.service) ?? extractId(data.service),\n startTime,\n })\n }\n\n return resolved\n }\n\n // Single-resource fallback (current behavior)\n if (!data.resource || !data.startTime) {\n return []\n }\n\n const resource = extractId(data.resource)\n if (resource === undefined || resource === '') {\n return []\n }\n\n return [\n {\n endTime: data.endTime as string,\n guestCount: (data.guestCount as number) ?? 1,\n resource,\n service: extractId(data.service),\n startTime: data.startTime as string,\n },\n ]\n}\n\n// Exported for unit testing. Returns the underlying id value from a relationship\n// field which Payload represents as either the raw id (string for Mongo, number\n// for Postgres) or a populated document `{ id, ... }`.\n//\n// Note: `0` is a valid numeric Postgres id but rare; we still return it rather\n// than treat it as missing.\nexport function extractId(value: unknown): number | string | undefined {\n if (typeof value === 'string' && value) {\n return value\n }\n if (typeof value === 'number') {\n return value\n }\n if (value && typeof value === 'object' && 'id' in value) {\n return (value as { id: number | string }).id\n }\n return undefined\n}\n"],"names":["ValidationError","resolveReservationItems","data","items","length","resolved","seen","Set","i","item","resource","extractId","startTime","undefined","errors","message","path","key","has","add","push","endTime","guestCount","service","value","id"],"mappings":"AAAA,SAASA,eAAe,QAAQ,UAAS;AAUzC;;;;;;;;;;CAUC,GACD,OAAO,SAASC,wBAAwBC,IAA6B;IACnE,MAAMC,QAAQD,KAAKC,KAAK;IAExB,IAAIA,SAASA,MAAMC,MAAM,GAAG,GAAG;QAC7B,MAAMC,WAA2B,EAAE;QACnC,MAAMC,OAAO,IAAIC;QAEjB,IAAK,IAAIC,IAAI,GAAGA,IAAIL,MAAMC,MAAM,EAAEI,IAAK;YACrC,MAAMC,OAAON,KAAK,CAACK,EAAE;YACrB,MAAME,WAAWC,UAAUF,KAAKC,QAAQ,KAAKC,UAAUT,KAAKQ,QAAQ;YACpE,MAAME,YAAY,AAACH,KAAKG,SAAS,IAAgBV,KAAKU,SAAS;YAE/D,IAAIF,aAAaG,aAAaH,aAAa,IAAI;gBAC7C,MAAM,IAAIV,gBAAgB;oBACxBc,QAAQ;wBACN;4BACEC,SAAS,CAAC,KAAK,EAAEP,EAAE,sBAAsB,CAAC;4BAC1CQ,MAAM,CAAC,MAAM,EAAER,EAAE,SAAS,CAAC;wBAC7B;qBACD;gBACH;YACF;YAEA,IAAI,CAACI,WAAW;gBACd,MAAM,IAAIZ,gBAAgB;oBACxBc,QAAQ;wBACN;4BACEC,SAAS,CAAC,KAAK,EAAEP,EAAE,uBAAuB,CAAC;4BAC3CQ,MAAM,CAAC,MAAM,EAAER,EAAE,UAAU,CAAC;wBAC9B;qBACD;gBACH;YACF;YAEA,MAAMS,MAAM,GAAGP,SAAS,EAAE,EAAEE,WAAW;YACvC,IAAIN,KAAKY,GAAG,CAACD,MAAM;gBACjB,MAAM,IAAIjB,gBAAgB;oBACxBc,QAAQ;wBACN;4BACEC,SAAS,CAAC,wBAAwB,EAAEP,EAAE,uDAAuD,CAAC;4BAC9FQ,MAAM,CAAC,MAAM,EAAER,EAAE,UAAU,CAAC;wBAC9B;qBACD;gBACH;YACF;YACAF,KAAKa,GAAG,CAACF;YAETZ,SAASe,IAAI,CAAC;gBACZC,SAAS,AAACZ,KAAKY,OAAO,IAAgBnB,KAAKmB,OAAO;gBAClDC,YAAY,AAACb,KAAKa,UAAU,IAAgBpB,KAAKoB,UAAU,IAAe;gBAC1EZ;gBACAa,SAASZ,UAAUF,KAAKc,OAAO,KAAKZ,UAAUT,KAAKqB,OAAO;gBAC1DX;YACF;QACF;QAEA,OAAOP;IACT;IAEA,8CAA8C;IAC9C,IAAI,CAACH,KAAKQ,QAAQ,IAAI,CAACR,KAAKU,SAAS,EAAE;QACrC,OAAO,EAAE;IACX;IAEA,MAAMF,WAAWC,UAAUT,KAAKQ,QAAQ;IACxC,IAAIA,aAAaG,aAAaH,aAAa,IAAI;QAC7C,OAAO,EAAE;IACX;IAEA,OAAO;QACL;YACEW,SAASnB,KAAKmB,OAAO;YACrBC,YAAY,AAACpB,KAAKoB,UAAU,IAAe;YAC3CZ;YACAa,SAASZ,UAAUT,KAAKqB,OAAO;YAC/BX,WAAWV,KAAKU,SAAS;QAC3B;KACD;AACH;AAEA,iFAAiF;AACjF,gFAAgF;AAChF,uDAAuD;AACvD,EAAE;AACF,+EAA+E;AAC/E,4BAA4B;AAC5B,OAAO,SAASD,UAAUa,KAAc;IACtC,IAAI,OAAOA,UAAU,YAAYA,OAAO;QACtC,OAAOA;IACT;IACA,IAAI,OAAOA,UAAU,UAAU;QAC7B,OAAOA;IACT;IACA,IAAIA,SAAS,OAAOA,UAAU,YAAY,QAAQA,OAAO;QACvD,OAAO,AAACA,MAAkCC,EAAE;IAC9C;IACA,OAAOZ;AACT"}
1
+ {"version":3,"sources":["../../src/utilities/resolveReservationItems.ts"],"sourcesContent":["import { ValidationError } from 'payload'\n\nimport { doRangesOverlap } from './slotUtils.js'\n\nexport type ResolvedItem = {\n endTime: string\n /** True when this item was synthesised from the top-level resource/startTime. */\n fromParent?: boolean\n guestCount: number\n resource: number | string\n service?: number | string\n startTime: string\n}\n\n/**\n * Normalize reservation data into a list of resource-level items.\n *\n * - If items[] is populated -> return items (filling defaults from parent), then\n * append a synthesised item for the top-level resource/startTime/endTime UNLESS\n * an items[] entry can already be shown to cover that same window (see B1 in\n * the synthesis step below) — this is what makes the top-level `resource`\n * conflict-checked even when items[] never names it.\n * Items missing startTime or resource throw a ValidationError.\n * Duplicate (resource, startTime) pairs throw a ValidationError.\n * An inverted top-level (parent) window throws a ValidationError too — UNLESS\n * `options.lenient` is set, in which case parent synthesis is silently\n * skipped instead (see the lenient-mode note below).\n * - If items[] is empty/absent -> return single item from top-level fields\n *\n * Every downstream function (conflict check, endTime calc, availability)\n * works with ResolvedItem[], never with raw reservation data.\n */\nexport function resolveReservationItems(\n data: Record<string, unknown>,\n options?: { lenient?: boolean },\n): ResolvedItem[] {\n const lenient = options?.lenient ?? false\n const items = data.items as Array<Record<string, unknown>> | undefined\n\n if (items && items.length > 0) {\n const resolved: ResolvedItem[] = []\n const seen = new Set<string>()\n\n for (let i = 0; i < items.length; i++) {\n const item = items[i]\n const resource = extractId(item.resource) ?? extractId(data.resource)\n const startTime = (item.startTime as string) ?? (data.startTime as string)\n\n if (resource === undefined || resource === '') {\n throw new ValidationError({\n errors: [\n {\n message: `Item ${i} is missing a resource`,\n path: `items.${i}.resource`,\n },\n ],\n })\n }\n\n if (!startTime) {\n throw new ValidationError({\n errors: [\n {\n message: `Item ${i} is missing a startTime`,\n path: `items.${i}.startTime`,\n },\n ],\n })\n }\n\n const key = `${resource}::${startTime}`\n if (seen.has(key)) {\n throw new ValidationError({\n errors: [\n {\n message: `Duplicate booking: item ${i} has the same resource and startTime as a previous item`,\n path: `items.${i}.startTime`,\n },\n ],\n })\n }\n seen.add(key)\n\n resolved.push({\n endTime: (item.endTime as string) ?? (data.endTime as string),\n guestCount: (item.guestCount as number) ?? (data.guestCount as number) ?? 1,\n resource,\n service: extractId(item.service) ?? extractId(data.service),\n startTime,\n })\n }\n\n // The stored row occupies its top-level `resource` for every OTHER booking's\n // conflict check (buildCoarseOverlapQuery matches top level OR items[]), so\n // it must occupy it for its own check too. Sharing a resource id with an\n // items[] entry is NOT sufficient to skip synthesis — an items[] entry for\n // the same resource at an unrelated time is a genuinely separate occupancy,\n // and treating \"same resource anywhere\" as \"already covered\" reopens the\n // exact double-booking class this function exists to close (a caller can\n // list resource A at one time in items[] while the top-level fields book A\n // at a completely different, uncovered time). Synthesis is skipped only\n // when an items[] entry for the SAME resource can be shown to cover the\n // SAME window:\n // (a) an exact startTime match — needs no endTime, so it still works when\n // this function runs before endTime is computed (validateActive and\n // calculateEndTime both call it before calculateEndTime has run); or\n // (b) both endTimes are known and the windows overlap — covers\n // calculateEndTime's multi-resource branch, which can overwrite the\n // top-level startTime/endTime to SPAN every item, so by the time this\n // function runs again (e.g. from validateConflicts) the parent's\n // window no longer starts at the same instant as any one item even\n // though that item's own window is fully contained in it.\n // When neither can be shown, synthesize: a redundant-but-harmless extra\n // check is far cheaper than a silently missed one.\n const parentResource = extractId(data.resource)\n const parentStart = data.startTime as string\n const parentEnd = data.endTime as string | undefined\n\n // An inverted parent window can never overlap anything, so the coverage test\n // below would always say \"not covered\" and synthesise a phantom item that\n // conflicts with nothing. Reject it at the source instead — it is malformed\n // input, not a case to paper over.\n //\n // Lenient mode (reservationOccupancies only — see AvailabilityService.ts)\n // resolves ALREADY-STORED documents, which can carry an inverted window\n // from a context.skipReservationHooks write or data predating this check.\n // A read must never crash over one malformed row, and — critically — it\n // must not lose the real items[] occupancies already resolved above by\n // throwing out of this function entirely. So lenient mode just skips\n // parent synthesis here instead of throwing: precisely the pre-check\n // behavior minus the (harmless but pointless) phantom item. The write\n // path never passes `lenient`, so it keeps the hard rejection.\n if (parentStart && parentEnd && new Date(parentEnd) <= new Date(parentStart)) {\n if (lenient) {\n return resolved\n }\n throw new ValidationError({\n errors: [{ message: 'endTime must be after startTime', path: 'endTime' }],\n })\n }\n\n if (parentResource !== undefined && parentResource !== '' && parentStart) {\n const parentAlreadyItemized = resolved.some((item) => {\n // String-compare ids: a raw id (string for Mongo, number for Postgres)\n // and a populated relationship's extracted `.id` should match even if\n // one side came through as a different primitive type than the other.\n if (String(item.resource) !== String(parentResource)) {\n return false\n }\n if (item.startTime === parentStart) {\n return true\n }\n return Boolean(\n parentEnd &&\n item.endTime &&\n doRangesOverlap(\n new Date(parentStart),\n new Date(parentEnd),\n new Date(item.startTime),\n new Date(item.endTime),\n ),\n )\n })\n\n if (!parentAlreadyItemized) {\n resolved.push({\n endTime: parentEnd as string,\n fromParent: true,\n guestCount: (data.guestCount as number) ?? 1,\n resource: parentResource,\n service: extractId(data.service),\n startTime: parentStart,\n })\n }\n }\n\n return resolved\n }\n\n // Single-resource fallback (current behavior)\n if (!data.resource || !data.startTime) {\n return []\n }\n\n const resource = extractId(data.resource)\n if (resource === undefined || resource === '') {\n return []\n }\n\n return [\n {\n endTime: data.endTime as string,\n guestCount: (data.guestCount as number) ?? 1,\n resource,\n service: extractId(data.service),\n startTime: data.startTime as string,\n },\n ]\n}\n\n// Exported for unit testing. Returns the underlying id value from a relationship\n// field which Payload represents as either the raw id (string for Mongo, number\n// for Postgres) or a populated document `{ id, ... }`.\n//\n// Note: `0` is a valid numeric Postgres id but rare; we still return it rather\n// than treat it as missing.\nexport function extractId(value: unknown): number | string | undefined {\n if (typeof value === 'string' && value) {\n return value\n }\n if (typeof value === 'number') {\n return value\n }\n if (value && typeof value === 'object' && 'id' in value) {\n return (value as { id: number | string }).id\n }\n return undefined\n}\n"],"names":["ValidationError","doRangesOverlap","resolveReservationItems","data","options","lenient","items","length","resolved","seen","Set","i","item","resource","extractId","startTime","undefined","errors","message","path","key","has","add","push","endTime","guestCount","service","parentResource","parentStart","parentEnd","Date","parentAlreadyItemized","some","String","Boolean","fromParent","value","id"],"mappings":"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"}
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Retry a write that lost a transaction-level lock race.
3
+ *
4
+ * `acquireBookingLock` makes concurrent bookings for one resource contend on a
5
+ * single document so the database serializes them. On MongoDB the loser does
6
+ * not wait — it aborts immediately with a WriteConflict. That is correct for a
7
+ * `quantity: 1` resource (only one booking should win anyway) but wrong for a
8
+ * `quantity: 3` one, where two more bookings legitimately fit. Measured
9
+ * without retry: 8 simultaneous bookings against a `quantity: 3` resource
10
+ * persisted only 1.
11
+ *
12
+ * Retrying re-runs the whole operation on a fresh transaction. The retry takes
13
+ * the lock cleanly, re-reads availability — now seeing the winner's committed
14
+ * booking — and is admitted or rejected by `validateConflicts` on the merits.
15
+ */
16
+ /**
17
+ * Whether an error means "the database refused this transaction because
18
+ * another one touched the same rows; running it again may succeed."
19
+ *
20
+ * Detection is by the driver's own structured signals, never by message text.
21
+ * MongoDB tags exactly this class with the `TransientTransactionError` label,
22
+ * which is the retry contract its drivers document; Postgres uses SQLSTATE;
23
+ * SQLite/libsql use a `code` string in the `SQLITE_BUSY`/`SQLITE_LOCKED*`
24
+ * family.
25
+ */
26
+ export declare function isTransientWriteConflict(error: unknown): boolean;
27
+ /**
28
+ * Run `operation`, retrying only transient write conflicts.
29
+ *
30
+ * Every other error — a genuine booking conflict, a validation failure, an
31
+ * access denial — propagates on the first attempt. Retrying those would turn a
32
+ * clean rejection into a slow one.
33
+ *
34
+ * Backoff is exponential with full jitter, so a burst of losers does not
35
+ * synchronise into a second stampede on the same document.
36
+ *
37
+ * Pass `req` whenever the operation writes through the Payload Local API. Every
38
+ * attempt re-invokes a closure over the SAME `req`, and Payload's
39
+ * `initTransaction` JOINS an existing `req.transactionID` rather than opening a
40
+ * fresh transaction — so a transaction id left behind by a failed attempt would
41
+ * make the next attempt re-enter a transaction the database already discarded.
42
+ * Payload's own operations normally clean up after themselves (`killTransaction`
43
+ * deletes the id from their catch), with one gap they cannot close: when
44
+ * `beginTransaction` ITSELF rejects, `initTransaction` has already stored the
45
+ * rejected promise on the req and `killTransaction`'s guard skips promises, so
46
+ * the req stays poisoned — every later `initTransaction` short-circuits on
47
+ * `instanceof Promise` and re-throws the first error without touching the
48
+ * database. Clearing a leftover here closes it. See dev/retryTransaction.spec.ts.
49
+ *
50
+ * Do NOT attribute SQLite's "raising the retry budget changes nothing"
51
+ * measurement to this. That has a different, independent cause: `@payloadcms/
52
+ * drizzle` rethrows the driver's error as a bare `Error`, so
53
+ * `isTransientWriteConflict` returns FALSE and this loop throws on attempt 1 —
54
+ * attempts 2..N never happen, and poisoning never gets a chance to matter. See
55
+ * README's "Concurrent booking: database adapter support". The shape this
56
+ * clearing actually protects is any caller that swallows a
57
+ * begin-transaction failure and then keeps using the same req (see the expiry
58
+ * sweep in HoldService.takeHold).
59
+ */
60
+ export declare function retryOnWriteConflict<T>(operation: () => Promise<T>, { attempts, baseDelayMs, req, }?: {
61
+ attempts?: number;
62
+ baseDelayMs?: number;
63
+ req?: {
64
+ transactionID?: unknown;
65
+ };
66
+ }): Promise<T>;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Retry a write that lost a transaction-level lock race.
3
+ *
4
+ * `acquireBookingLock` makes concurrent bookings for one resource contend on a
5
+ * single document so the database serializes them. On MongoDB the loser does
6
+ * not wait — it aborts immediately with a WriteConflict. That is correct for a
7
+ * `quantity: 1` resource (only one booking should win anyway) but wrong for a
8
+ * `quantity: 3` one, where two more bookings legitimately fit. Measured
9
+ * without retry: 8 simultaneous bookings against a `quantity: 3` resource
10
+ * persisted only 1.
11
+ *
12
+ * Retrying re-runs the whole operation on a fresh transaction. The retry takes
13
+ * the lock cleanly, re-reads availability — now seeing the winner's committed
14
+ * booking — and is admitted or rejected by `validateConflicts` on the merits.
15
+ */ /** MongoDB WriteConflict. */ const MONGO_WRITE_CONFLICT = 112;
16
+ /** Postgres serialization_failure and deadlock_detected. */ const POSTGRES_SERIALIZATION_FAILURES = new Set([
17
+ '40P01',
18
+ '40001'
19
+ ]);
20
+ /**
21
+ * SQLite/libsql busy-or-locked family. Only reachable when the adapter is
22
+ * given a truthy `transactionOptions` — otherwise `beginTransaction` is a
23
+ * no-op and nothing ever contends. `SQLITE_BUSY` and plain `SQLITE_LOCKED`
24
+ * are the same family as the qualified variant measured below; prefix match
25
+ * (not an exact-set lookup like the Postgres codes above) because
26
+ * SQLite/libsql qualify the base code with a reason suffix (`_SHAREDCACHE`,
27
+ * `_SNAPSHOT`, ...) — the prefix is still the driver's own structured
28
+ * signal, never message text.
29
+ *
30
+ * KNOWN GAP, confirmed by direct inspection, not assumption: for a loser
31
+ * that aborts at `beginTransaction` itself (SQLite's actual failure mode —
32
+ * a second write transaction cannot even open while one is held, rather
33
+ * than queuing for it), this check can never fire in practice. The real
34
+ * driver error — a `LibsqlError` with `code: 'SQLITE_LOCKED_SHAREDCACHE'` —
35
+ * is caught *inside* `@payloadcms/drizzle`'s own `beginTransaction.js` (a
36
+ * Payload-core dependency, not this plugin) and re-thrown as a bare
37
+ * `new Error('Error: cannot begin transaction: ...')`: no `code`, no
38
+ * `cause`, generic `name`. By the time it reaches this function the
39
+ * structured signal is already gone, so retry never engages for that path
40
+ * — measured: burst of 8 against a `quantity: 3` resource with retry
41
+ * recovers only 1 of 3, identical to no retry at all, and raising the
42
+ * retry budget from 5 to 30 attempts makes no difference. This match is
43
+ * kept because it is still correct for any SQLite/libsql error shape that
44
+ * *does* preserve `code` (a mid-transaction conflict would be a different
45
+ * code path), and matching on the wrapped error's message text to plug this
46
+ * specific gap is exactly what this project's structured-signal-only
47
+ * constraint forbids.
48
+ */ const SQLITE_BUSY_OR_LOCKED_PREFIXES = [
49
+ 'SQLITE_BUSY',
50
+ 'SQLITE_LOCKED'
51
+ ];
52
+ /**
53
+ * Whether an error means "the database refused this transaction because
54
+ * another one touched the same rows; running it again may succeed."
55
+ *
56
+ * Detection is by the driver's own structured signals, never by message text.
57
+ * MongoDB tags exactly this class with the `TransientTransactionError` label,
58
+ * which is the retry contract its drivers document; Postgres uses SQLSTATE;
59
+ * SQLite/libsql use a `code` string in the `SQLITE_BUSY`/`SQLITE_LOCKED*`
60
+ * family.
61
+ */ export function isTransientWriteConflict(error) {
62
+ if (!error || typeof error !== 'object') {
63
+ return false;
64
+ }
65
+ const { code, errorLabels } = error;
66
+ if (Array.isArray(errorLabels) && errorLabels.includes('TransientTransactionError')) {
67
+ return true;
68
+ }
69
+ if (code === MONGO_WRITE_CONFLICT) {
70
+ return true;
71
+ }
72
+ if (typeof code !== 'string') {
73
+ return false;
74
+ }
75
+ return POSTGRES_SERIALIZATION_FAILURES.has(code) || SQLITE_BUSY_OR_LOCKED_PREFIXES.some((prefix)=>code.startsWith(prefix));
76
+ }
77
+ /**
78
+ * Run `operation`, retrying only transient write conflicts.
79
+ *
80
+ * Every other error — a genuine booking conflict, a validation failure, an
81
+ * access denial — propagates on the first attempt. Retrying those would turn a
82
+ * clean rejection into a slow one.
83
+ *
84
+ * Backoff is exponential with full jitter, so a burst of losers does not
85
+ * synchronise into a second stampede on the same document.
86
+ *
87
+ * Pass `req` whenever the operation writes through the Payload Local API. Every
88
+ * attempt re-invokes a closure over the SAME `req`, and Payload's
89
+ * `initTransaction` JOINS an existing `req.transactionID` rather than opening a
90
+ * fresh transaction — so a transaction id left behind by a failed attempt would
91
+ * make the next attempt re-enter a transaction the database already discarded.
92
+ * Payload's own operations normally clean up after themselves (`killTransaction`
93
+ * deletes the id from their catch), with one gap they cannot close: when
94
+ * `beginTransaction` ITSELF rejects, `initTransaction` has already stored the
95
+ * rejected promise on the req and `killTransaction`'s guard skips promises, so
96
+ * the req stays poisoned — every later `initTransaction` short-circuits on
97
+ * `instanceof Promise` and re-throws the first error without touching the
98
+ * database. Clearing a leftover here closes it. See dev/retryTransaction.spec.ts.
99
+ *
100
+ * Do NOT attribute SQLite's "raising the retry budget changes nothing"
101
+ * measurement to this. That has a different, independent cause: `@payloadcms/
102
+ * drizzle` rethrows the driver's error as a bare `Error`, so
103
+ * `isTransientWriteConflict` returns FALSE and this loop throws on attempt 1 —
104
+ * attempts 2..N never happen, and poisoning never gets a chance to matter. See
105
+ * README's "Concurrent booking: database adapter support". The shape this
106
+ * clearing actually protects is any caller that swallows a
107
+ * begin-transaction failure and then keeps using the same req (see the expiry
108
+ * sweep in HoldService.takeHold).
109
+ */ export async function retryOnWriteConflict(operation, { attempts = 5, baseDelayMs = 10, req } = {}) {
110
+ let lastError;
111
+ // Only leftovers from a failed attempt are ours to clear. A req that arrives
112
+ // already inside a transaction belongs to an enclosing caller: dropping that
113
+ // id would silently detach the retried write from their unit of work.
114
+ const callerOwnsTransaction = req ? req.transactionID !== undefined : false;
115
+ for(let attempt = 0; attempt < attempts; attempt++){
116
+ if (attempt > 0 && req && !callerOwnsTransaction && req.transactionID !== undefined) {
117
+ delete req.transactionID;
118
+ }
119
+ try {
120
+ return await operation();
121
+ } catch (error) {
122
+ if (!isTransientWriteConflict(error)) {
123
+ throw error;
124
+ }
125
+ lastError = error;
126
+ if (attempt < attempts - 1) {
127
+ const ceiling = baseDelayMs * 2 ** attempt;
128
+ await new Promise((resolve)=>setTimeout(resolve, Math.random() * ceiling));
129
+ }
130
+ }
131
+ }
132
+ throw lastError;
133
+ }
134
+
135
+ //# sourceMappingURL=retryOnWriteConflict.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utilities/retryOnWriteConflict.ts"],"sourcesContent":["/**\n * Retry a write that lost a transaction-level lock race.\n *\n * `acquireBookingLock` makes concurrent bookings for one resource contend on a\n * single document so the database serializes them. On MongoDB the loser does\n * not wait — it aborts immediately with a WriteConflict. That is correct for a\n * `quantity: 1` resource (only one booking should win anyway) but wrong for a\n * `quantity: 3` one, where two more bookings legitimately fit. Measured\n * without retry: 8 simultaneous bookings against a `quantity: 3` resource\n * persisted only 1.\n *\n * Retrying re-runs the whole operation on a fresh transaction. The retry takes\n * the lock cleanly, re-reads availability — now seeing the winner's committed\n * booking — and is admitted or rejected by `validateConflicts` on the merits.\n */\n\n/** MongoDB WriteConflict. */\nconst MONGO_WRITE_CONFLICT = 112\n\n/** Postgres serialization_failure and deadlock_detected. */\nconst POSTGRES_SERIALIZATION_FAILURES = new Set(['40P01', '40001'])\n\n/**\n * SQLite/libsql busy-or-locked family. Only reachable when the adapter is\n * given a truthy `transactionOptions` — otherwise `beginTransaction` is a\n * no-op and nothing ever contends. `SQLITE_BUSY` and plain `SQLITE_LOCKED`\n * are the same family as the qualified variant measured below; prefix match\n * (not an exact-set lookup like the Postgres codes above) because\n * SQLite/libsql qualify the base code with a reason suffix (`_SHAREDCACHE`,\n * `_SNAPSHOT`, ...) — the prefix is still the driver's own structured\n * signal, never message text.\n *\n * KNOWN GAP, confirmed by direct inspection, not assumption: for a loser\n * that aborts at `beginTransaction` itself (SQLite's actual failure mode —\n * a second write transaction cannot even open while one is held, rather\n * than queuing for it), this check can never fire in practice. The real\n * driver error — a `LibsqlError` with `code: 'SQLITE_LOCKED_SHAREDCACHE'` —\n * is caught *inside* `@payloadcms/drizzle`'s own `beginTransaction.js` (a\n * Payload-core dependency, not this plugin) and re-thrown as a bare\n * `new Error('Error: cannot begin transaction: ...')`: no `code`, no\n * `cause`, generic `name`. By the time it reaches this function the\n * structured signal is already gone, so retry never engages for that path\n * — measured: burst of 8 against a `quantity: 3` resource with retry\n * recovers only 1 of 3, identical to no retry at all, and raising the\n * retry budget from 5 to 30 attempts makes no difference. This match is\n * kept because it is still correct for any SQLite/libsql error shape that\n * *does* preserve `code` (a mid-transaction conflict would be a different\n * code path), and matching on the wrapped error's message text to plug this\n * specific gap is exactly what this project's structured-signal-only\n * constraint forbids.\n */\nconst SQLITE_BUSY_OR_LOCKED_PREFIXES = ['SQLITE_BUSY', 'SQLITE_LOCKED']\n\n/**\n * Whether an error means \"the database refused this transaction because\n * another one touched the same rows; running it again may succeed.\"\n *\n * Detection is by the driver's own structured signals, never by message text.\n * MongoDB tags exactly this class with the `TransientTransactionError` label,\n * which is the retry contract its drivers document; Postgres uses SQLSTATE;\n * SQLite/libsql use a `code` string in the `SQLITE_BUSY`/`SQLITE_LOCKED*`\n * family.\n */\nexport function isTransientWriteConflict(error: unknown): boolean {\n if (!error || typeof error !== 'object') {\n return false\n }\n\n const { code, errorLabels } = error as { code?: unknown; errorLabels?: unknown }\n\n if (Array.isArray(errorLabels) && errorLabels.includes('TransientTransactionError')) {\n return true\n }\n\n if (code === MONGO_WRITE_CONFLICT) {\n return true\n }\n\n if (typeof code !== 'string') {\n return false\n }\n\n return (\n POSTGRES_SERIALIZATION_FAILURES.has(code) ||\n SQLITE_BUSY_OR_LOCKED_PREFIXES.some((prefix) => code.startsWith(prefix))\n )\n}\n\n/**\n * Run `operation`, retrying only transient write conflicts.\n *\n * Every other error — a genuine booking conflict, a validation failure, an\n * access denial — propagates on the first attempt. Retrying those would turn a\n * clean rejection into a slow one.\n *\n * Backoff is exponential with full jitter, so a burst of losers does not\n * synchronise into a second stampede on the same document.\n *\n * Pass `req` whenever the operation writes through the Payload Local API. Every\n * attempt re-invokes a closure over the SAME `req`, and Payload's\n * `initTransaction` JOINS an existing `req.transactionID` rather than opening a\n * fresh transaction — so a transaction id left behind by a failed attempt would\n * make the next attempt re-enter a transaction the database already discarded.\n * Payload's own operations normally clean up after themselves (`killTransaction`\n * deletes the id from their catch), with one gap they cannot close: when\n * `beginTransaction` ITSELF rejects, `initTransaction` has already stored the\n * rejected promise on the req and `killTransaction`'s guard skips promises, so\n * the req stays poisoned — every later `initTransaction` short-circuits on\n * `instanceof Promise` and re-throws the first error without touching the\n * database. Clearing a leftover here closes it. See dev/retryTransaction.spec.ts.\n *\n * Do NOT attribute SQLite's \"raising the retry budget changes nothing\"\n * measurement to this. That has a different, independent cause: `@payloadcms/\n * drizzle` rethrows the driver's error as a bare `Error`, so\n * `isTransientWriteConflict` returns FALSE and this loop throws on attempt 1 —\n * attempts 2..N never happen, and poisoning never gets a chance to matter. See\n * README's \"Concurrent booking: database adapter support\". The shape this\n * clearing actually protects is any caller that swallows a\n * begin-transaction failure and then keeps using the same req (see the expiry\n * sweep in HoldService.takeHold).\n */\nexport async function retryOnWriteConflict<T>(\n operation: () => Promise<T>,\n {\n attempts = 5,\n baseDelayMs = 10,\n req,\n }: { attempts?: number; baseDelayMs?: number; req?: { transactionID?: unknown } } = {},\n): Promise<T> {\n let lastError: unknown\n\n // Only leftovers from a failed attempt are ours to clear. A req that arrives\n // already inside a transaction belongs to an enclosing caller: dropping that\n // id would silently detach the retried write from their unit of work.\n const callerOwnsTransaction = req ? req.transactionID !== undefined : false\n\n for (let attempt = 0; attempt < attempts; attempt++) {\n if (attempt > 0 && req && !callerOwnsTransaction && req.transactionID !== undefined) {\n delete req.transactionID\n }\n\n try {\n return await operation()\n } catch (error) {\n if (!isTransientWriteConflict(error)) {\n throw error\n }\n\n lastError = error\n\n if (attempt < attempts - 1) {\n const ceiling = baseDelayMs * 2 ** attempt\n await new Promise((resolve) => setTimeout(resolve, Math.random() * ceiling))\n }\n }\n }\n\n throw lastError\n}\n"],"names":["MONGO_WRITE_CONFLICT","POSTGRES_SERIALIZATION_FAILURES","Set","SQLITE_BUSY_OR_LOCKED_PREFIXES","isTransientWriteConflict","error","code","errorLabels","Array","isArray","includes","has","some","prefix","startsWith","retryOnWriteConflict","operation","attempts","baseDelayMs","req","lastError","callerOwnsTransaction","transactionID","undefined","attempt","ceiling","Promise","resolve","setTimeout","Math","random"],"mappings":"AAAA;;;;;;;;;;;;;;CAcC,GAED,2BAA2B,GAC3B,MAAMA,uBAAuB;AAE7B,0DAA0D,GAC1D,MAAMC,kCAAkC,IAAIC,IAAI;IAAC;IAAS;CAAQ;AAElE;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,MAAMC,iCAAiC;IAAC;IAAe;CAAgB;AAEvE;;;;;;;;;CASC,GACD,OAAO,SAASC,yBAAyBC,KAAc;IACrD,IAAI,CAACA,SAAS,OAAOA,UAAU,UAAU;QACvC,OAAO;IACT;IAEA,MAAM,EAAEC,IAAI,EAAEC,WAAW,EAAE,GAAGF;IAE9B,IAAIG,MAAMC,OAAO,CAACF,gBAAgBA,YAAYG,QAAQ,CAAC,8BAA8B;QACnF,OAAO;IACT;IAEA,IAAIJ,SAASN,sBAAsB;QACjC,OAAO;IACT;IAEA,IAAI,OAAOM,SAAS,UAAU;QAC5B,OAAO;IACT;IAEA,OACEL,gCAAgCU,GAAG,CAACL,SACpCH,+BAA+BS,IAAI,CAAC,CAACC,SAAWP,KAAKQ,UAAU,CAACD;AAEpE;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgCC,GACD,OAAO,eAAeE,qBACpBC,SAA2B,EAC3B,EACEC,WAAW,CAAC,EACZC,cAAc,EAAE,EAChBC,GAAG,EAC4E,GAAG,CAAC,CAAC;IAEtF,IAAIC;IAEJ,6EAA6E;IAC7E,6EAA6E;IAC7E,sEAAsE;IACtE,MAAMC,wBAAwBF,MAAMA,IAAIG,aAAa,KAAKC,YAAY;IAEtE,IAAK,IAAIC,UAAU,GAAGA,UAAUP,UAAUO,UAAW;QACnD,IAAIA,UAAU,KAAKL,OAAO,CAACE,yBAAyBF,IAAIG,aAAa,KAAKC,WAAW;YACnF,OAAOJ,IAAIG,aAAa;QAC1B;QAEA,IAAI;YACF,OAAO,MAAMN;QACf,EAAE,OAAOX,OAAO;YACd,IAAI,CAACD,yBAAyBC,QAAQ;gBACpC,MAAMA;YACR;YAEAe,YAAYf;YAEZ,IAAImB,UAAUP,WAAW,GAAG;gBAC1B,MAAMQ,UAAUP,cAAc,KAAKM;gBACnC,MAAM,IAAIE,QAAQ,CAACC,UAAYC,WAAWD,SAASE,KAAKC,MAAM,KAAKL;YACrE;QACF;IACF;IAEA,MAAML;AACR"}