payload-reserve 2.3.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.
- package/README.md +218 -4
- package/dist/collections/Holds.d.ts +18 -0
- package/dist/collections/Holds.js +113 -0
- package/dist/collections/Holds.js.map +1 -0
- package/dist/collections/Reservations.js +8 -0
- package/dist/collections/Reservations.js.map +1 -1
- package/dist/collections/Resources.js +35 -1
- package/dist/collections/Resources.js.map +1 -1
- package/dist/collections/Services.js +41 -0
- package/dist/collections/Services.js.map +1 -1
- package/dist/components/CalendarView/LaneTimelineView.js +4 -5
- package/dist/components/CalendarView/LaneTimelineView.js.map +1 -1
- package/dist/components/CalendarView/index.js +55 -71
- package/dist/components/CalendarView/index.js.map +1 -1
- package/dist/components/DashboardWidget/DashboardWidgetServer.js +11 -74
- package/dist/components/DashboardWidget/DashboardWidgetServer.js.map +1 -1
- package/dist/components/DashboardWidget/fetchDashboardStats.d.ts +42 -0
- package/dist/components/DashboardWidget/fetchDashboardStats.js +113 -0
- package/dist/components/DashboardWidget/fetchDashboardStats.js.map +1 -0
- package/dist/defaults.d.ts +2 -0
- package/dist/defaults.js +12 -0
- package/dist/defaults.js.map +1 -1
- package/dist/endpoints/cancelBooking.js +43 -14
- package/dist/endpoints/cancelBooking.js.map +1 -1
- package/dist/endpoints/checkAvailability.js +27 -1
- package/dist/endpoints/checkAvailability.js.map +1 -1
- package/dist/endpoints/createBooking.js +108 -7
- package/dist/endpoints/createBooking.js.map +1 -1
- package/dist/endpoints/customerSearch.js +6 -0
- package/dist/endpoints/customerSearch.js.map +1 -1
- package/dist/endpoints/effectiveTimezone.js +1 -0
- package/dist/endpoints/effectiveTimezone.js.map +1 -1
- package/dist/endpoints/getSlots.js +27 -1
- package/dist/endpoints/getSlots.js.map +1 -1
- package/dist/endpoints/holdSlot.d.ts +12 -0
- package/dist/endpoints/holdSlot.js +126 -0
- package/dist/endpoints/holdSlot.js.map +1 -0
- package/dist/endpoints/releaseSlot.d.ts +8 -0
- package/dist/endpoints/releaseSlot.js +34 -0
- package/dist/endpoints/releaseSlot.js.map +1 -0
- package/dist/endpoints/resourceAvailability.d.ts +4 -0
- package/dist/endpoints/resourceAvailability.js +105 -4
- package/dist/endpoints/resourceAvailability.js.map +1 -1
- package/dist/hooks/holds/validateHoldSlot.d.ts +14 -0
- package/dist/hooks/holds/validateHoldSlot.js +90 -0
- package/dist/hooks/holds/validateHoldSlot.js.map +1 -0
- package/dist/hooks/reservations/acquireBookingLock.d.ts +30 -0
- package/dist/hooks/reservations/acquireBookingLock.js +71 -0
- package/dist/hooks/reservations/acquireBookingLock.js.map +1 -0
- package/dist/hooks/reservations/calculateEndTime.js +13 -1
- package/dist/hooks/reservations/calculateEndTime.js.map +1 -1
- package/dist/hooks/reservations/expandRequiredResources.js +3 -0
- package/dist/hooks/reservations/expandRequiredResources.js.map +1 -1
- package/dist/hooks/reservations/validateActive.d.ts +11 -0
- package/dist/hooks/reservations/validateActive.js +103 -0
- package/dist/hooks/reservations/validateActive.js.map +1 -0
- package/dist/hooks/reservations/validateConflicts.js +22 -1
- package/dist/hooks/reservations/validateConflicts.js.map +1 -1
- package/dist/hooks/reservations/validateGuestBooking.js +3 -0
- package/dist/hooks/reservations/validateGuestBooking.js.map +1 -1
- package/dist/hooks/shared/preventDeleteWhenReferenced.d.ts +42 -0
- package/dist/hooks/shared/preventDeleteWhenReferenced.js +87 -0
- package/dist/hooks/shared/preventDeleteWhenReferenced.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js.map +1 -1
- package/dist/plugin.js +179 -3
- package/dist/plugin.js.map +1 -1
- package/dist/services/AvailabilityService.d.ts +51 -5
- package/dist/services/AvailabilityService.js +315 -38
- package/dist/services/AvailabilityService.js.map +1 -1
- package/dist/services/HoldService.d.ts +53 -0
- package/dist/services/HoldService.js +169 -0
- package/dist/services/HoldService.js.map +1 -0
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js.map +1 -1
- package/dist/translations/ar.json +5 -1
- package/dist/translations/de.json +5 -1
- package/dist/translations/en.json +5 -1
- package/dist/translations/es.json +5 -1
- package/dist/translations/fa.json +5 -1
- package/dist/translations/fr.json +5 -1
- package/dist/translations/hi.json +5 -1
- package/dist/translations/id.json +5 -1
- package/dist/translations/pl.json +5 -1
- package/dist/translations/ru.json +5 -1
- package/dist/translations/tr.json +5 -1
- package/dist/translations/zh.json +5 -1
- package/dist/types.d.ts +32 -0
- package/dist/types.js.map +1 -1
- package/dist/utilities/calendarGrid.d.ts +44 -0
- package/dist/utilities/calendarGrid.js +67 -0
- package/dist/utilities/calendarGrid.js.map +1 -0
- package/dist/utilities/reservationChanges.d.ts +6 -1
- package/dist/utilities/reservationChanges.js +1 -1
- package/dist/utilities/reservationChanges.js.map +1 -1
- package/dist/utilities/reserveDebug.d.ts +26 -0
- package/dist/utilities/reserveDebug.js +35 -0
- package/dist/utilities/reserveDebug.js.map +1 -0
- package/dist/utilities/resolveReservationItems.d.ts +13 -2
- package/dist/utilities/resolveReservationItems.js +87 -2
- package/dist/utilities/resolveReservationItems.js.map +1 -1
- package/dist/utilities/retryOnWriteConflict.d.ts +66 -0
- package/dist/utilities/retryOnWriteConflict.js +135 -0
- package/dist/utilities/retryOnWriteConflict.js.map +1 -0
- package/dist/utilities/tenantTimezone.d.ts +77 -1
- package/dist/utilities/tenantTimezone.js +163 -2
- package/dist/utilities/tenantTimezone.js.map +1 -1
- package/dist/utilities/transactionSupport.d.ts +13 -0
- package/dist/utilities/transactionSupport.js +30 -0
- package/dist/utilities/transactionSupport.js.map +1 -0
- package/package.json +13 -13
|
@@ -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":"AAAA,SAASA,eAAe,QAAQ,UAAS;AAEzC,SAASC,eAAe,QAAQ,iBAAgB;AAYhD;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,SAASC,wBACdC,IAA6B,EAC7BC,OAA+B;IAE/B,MAAMC,UAAUD,SAASC,WAAW;IACpC,MAAMC,QAAQH,KAAKG,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,UAAUX,KAAKU,QAAQ;YACpE,MAAME,YAAY,AAACH,KAAKG,SAAS,IAAgBZ,KAAKY,SAAS;YAE/D,IAAIF,aAAaG,aAAaH,aAAa,IAAI;gBAC7C,MAAM,IAAIb,gBAAgB;oBACxBiB,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,IAAIf,gBAAgB;oBACxBiB,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,IAAIpB,gBAAgB;oBACxBiB,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,IAAgBrB,KAAKqB,OAAO;gBAClDC,YAAY,AAACb,KAAKa,UAAU,IAAgBtB,KAAKsB,UAAU,IAAe;gBAC1EZ;gBACAa,SAASZ,UAAUF,KAAKc,OAAO,KAAKZ,UAAUX,KAAKuB,OAAO;gBAC1DX;YACF;QACF;QAEA,6EAA6E;QAC7E,4EAA4E;QAC5E,yEAAyE;QACzE,2EAA2E;QAC3E,4EAA4E;QAC5E,yEAAyE;QACzE,yEAAyE;QACzE,2EAA2E;QAC3E,wEAAwE;QACxE,wEAAwE;QACxE,eAAe;QACf,2EAA2E;QAC3E,yEAAyE;QACzE,0EAA0E;QAC1E,gEAAgE;QAChE,yEAAyE;QACzE,2EAA2E;QAC3E,sEAAsE;QACtE,wEAAwE;QACxE,+DAA+D;QAC/D,wEAAwE;QACxE,mDAAmD;QACnD,MAAMY,iBAAiBb,UAAUX,KAAKU,QAAQ;QAC9C,MAAMe,cAAczB,KAAKY,SAAS;QAClC,MAAMc,YAAY1B,KAAKqB,OAAO;QAE9B,6EAA6E;QAC7E,0EAA0E;QAC1E,4EAA4E;QAC5E,mCAAmC;QACnC,EAAE;QACF,0EAA0E;QAC1E,wEAAwE;QACxE,0EAA0E;QAC1E,wEAAwE;QACxE,uEAAuE;QACvE,qEAAqE;QACrE,qEAAqE;QACrE,sEAAsE;QACtE,+DAA+D;QAC/D,IAAII,eAAeC,aAAa,IAAIC,KAAKD,cAAc,IAAIC,KAAKF,cAAc;YAC5E,IAAIvB,SAAS;gBACX,OAAOG;YACT;YACA,MAAM,IAAIR,gBAAgB;gBACxBiB,QAAQ;oBAAC;wBAAEC,SAAS;wBAAmCC,MAAM;oBAAU;iBAAE;YAC3E;QACF;QAEA,IAAIQ,mBAAmBX,aAAaW,mBAAmB,MAAMC,aAAa;YACxE,MAAMG,wBAAwBvB,SAASwB,IAAI,CAAC,CAACpB;gBAC3C,uEAAuE;gBACvE,sEAAsE;gBACtE,sEAAsE;gBACtE,IAAIqB,OAAOrB,KAAKC,QAAQ,MAAMoB,OAAON,iBAAiB;oBACpD,OAAO;gBACT;gBACA,IAAIf,KAAKG,SAAS,KAAKa,aAAa;oBAClC,OAAO;gBACT;gBACA,OAAOM,QACLL,aACEjB,KAAKY,OAAO,IACZvB,gBACE,IAAI6B,KAAKF,cACT,IAAIE,KAAKD,YACT,IAAIC,KAAKlB,KAAKG,SAAS,GACvB,IAAIe,KAAKlB,KAAKY,OAAO;YAG7B;YAEA,IAAI,CAACO,uBAAuB;gBAC1BvB,SAASe,IAAI,CAAC;oBACZC,SAASK;oBACTM,YAAY;oBACZV,YAAY,AAACtB,KAAKsB,UAAU,IAAe;oBAC3CZ,UAAUc;oBACVD,SAASZ,UAAUX,KAAKuB,OAAO;oBAC/BX,WAAWa;gBACb;YACF;QACF;QAEA,OAAOpB;IACT;IAEA,8CAA8C;IAC9C,IAAI,CAACL,KAAKU,QAAQ,IAAI,CAACV,KAAKY,SAAS,EAAE;QACrC,OAAO,EAAE;IACX;IAEA,MAAMF,WAAWC,UAAUX,KAAKU,QAAQ;IACxC,IAAIA,aAAaG,aAAaH,aAAa,IAAI;QAC7C,OAAO,EAAE;IACX;IAEA,OAAO;QACL;YACEW,SAASrB,KAAKqB,OAAO;YACrBC,YAAY,AAACtB,KAAKsB,UAAU,IAAe;YAC3CZ;YACAa,SAASZ,UAAUX,KAAKuB,OAAO;YAC/BX,WAAWZ,KAAKY,SAAS;QAC3B;KACD;AACH;AAEA,iFAAiF;AACjF,gFAAgF;AAChF,uDAAuD;AACvD,EAAE;AACF,+EAA+E;AAC/E,4BAA4B;AAC5B,OAAO,SAASD,UAAUsB,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,OAAOrB;AACT"}
|
|
@@ -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"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { Payload } from 'payload';
|
|
1
|
+
import type { Payload, PayloadRequest } from 'payload';
|
|
2
|
+
import type { ResolvedReservationPluginConfig } from '../types.js';
|
|
2
3
|
type CollectionLike = {
|
|
3
4
|
fields?: unknown[];
|
|
4
5
|
} | null | undefined;
|
|
@@ -33,9 +34,84 @@ export declare function tenantCollectionSlug(collection: CollectionLike, tenantF
|
|
|
33
34
|
export declare function getEffectiveTenantTimezone(args: {
|
|
34
35
|
globalTimezone: string;
|
|
35
36
|
payload: Payload;
|
|
37
|
+
req?: PayloadRequest;
|
|
36
38
|
scopedCollection: CollectionLike;
|
|
37
39
|
tenantField: string;
|
|
38
40
|
tenantId: null | string;
|
|
39
41
|
timezoneField: string;
|
|
40
42
|
}): Promise<string>;
|
|
43
|
+
/**
|
|
44
|
+
* Normalizes a relationship-shaped value down to its id: a raw string/number
|
|
45
|
+
* id, or an object carrying one — `{ id }`, the shape both a populated
|
|
46
|
+
* relationship and a client sending `{ "tenant": { "id": "..." } }` produce.
|
|
47
|
+
* Returns null for anything else (including an object with no usable `id`),
|
|
48
|
+
* so callers can fail closed on a shape they don't recognize instead of
|
|
49
|
+
* silently skipping whatever check they were about to run.
|
|
50
|
+
*/
|
|
51
|
+
export declare function normalizeRelationshipId(value: unknown): null | number | string;
|
|
52
|
+
/**
|
|
53
|
+
* Every tenant id a tenant-field value names, as a list.
|
|
54
|
+
*
|
|
55
|
+
* Exists because multi-tenant supports a `hasMany` tenant field, so the value
|
|
56
|
+
* can legitimately be an ARRAY of relationship-shaped entries.
|
|
57
|
+
* {@link normalizeRelationshipId} returns null for an array, and the caller
|
|
58
|
+
* fails closed on null — which turned a supported multi-tenant configuration
|
|
59
|
+
* into a blanket 403 on every authenticated booking that carried an explicit
|
|
60
|
+
* tenant (a total booking outage for that install, with no diagnostic).
|
|
61
|
+
*
|
|
62
|
+
* - `[]` -> `[]`: an empty array writes no tenant, so there is nothing to
|
|
63
|
+
* authorize; the caller permits it, exactly as it does for an absent value.
|
|
64
|
+
* - a one-or-many array -> one id per entry, all of which the caller must be
|
|
65
|
+
* authorized for (any single unrecognized entry poisons the whole value).
|
|
66
|
+
* - a scalar / `{ id }` -> a single-element list.
|
|
67
|
+
* - anything else -> null, so the caller can still fail closed, loudly.
|
|
68
|
+
*/
|
|
69
|
+
export declare function normalizeRelationshipIds(value: unknown): Array<number | string> | null;
|
|
70
|
+
/**
|
|
71
|
+
* Whether an authenticated caller may write the tenant value present in
|
|
72
|
+
* `data[config.multiTenant.tenantField]` onto a new document in
|
|
73
|
+
* `config.slugs.reservations`.
|
|
74
|
+
*
|
|
75
|
+
* Exists because delegating `overrideAccess` is NOT sufficient on its own:
|
|
76
|
+
* multi-tenant's tenant-field `validate` only checks presence, and Payload's
|
|
77
|
+
* `create` operation (`executeAccess`) only checks the TRUTHINESS of a
|
|
78
|
+
* collection access result — unlike read/update/delete, which apply a
|
|
79
|
+
* returned `Where` via `combineQueries` against a real document. So MT's own
|
|
80
|
+
* tenant-scoped `create` access can't reject an explicit foreign tenant
|
|
81
|
+
* either; an authenticated caller can write one straight through regardless
|
|
82
|
+
* of `overrideAccess`. This closes that gap the same way
|
|
83
|
+
* {@link getEffectiveTenantTimezone} closes it for a client-supplied tenant
|
|
84
|
+
* *cookie*: an access-checked (`overrideAccess: false`) probe read against
|
|
85
|
+
* the tenants collection for the *submitted* tenant id — a lookup that finds
|
|
86
|
+
* nothing means the caller isn't a member (or a multi-tenant super-admin).
|
|
87
|
+
*
|
|
88
|
+
* PRECONDITION — this is a real membership check only when the caller
|
|
89
|
+
* authenticates against the SAME collection multi-tenant wraps as its
|
|
90
|
+
* admin/tenant-owning collection. `withTenantAccess` only applies its
|
|
91
|
+
* membership constraint when `req.user.collection === adminUsersSlug`;
|
|
92
|
+
* otherwise it falls back to the tenants collection's own (here: default,
|
|
93
|
+
* unrestricted) access function, which only requires `Boolean(req.user)`. In
|
|
94
|
+
* this plugin's STANDALONE mode (`userCollection` unset), a customer
|
|
95
|
+
* authenticates against `slugs.customers` — a collection multi-tenant never
|
|
96
|
+
* wraps — so this probe passes for ANY tenant id for a logged-in customer.
|
|
97
|
+
* `src/plugin.ts`'s boot diagnostic warns about that configuration. It is
|
|
98
|
+
* also a no-op if the host set multi-tenant's own
|
|
99
|
+
* `useTenantsCollectionAccess: false`, which is not detectable from here.
|
|
100
|
+
*
|
|
101
|
+
* Returns true — nothing to check — when the reservations collection isn't
|
|
102
|
+
* tenant-scoped at all, or `data` doesn't carry an explicit tenant value (MT's
|
|
103
|
+
* own access-checked `defaultValue` applies in that case; see
|
|
104
|
+
* `getEffectiveTenantTimezone`'s doc comment for why that path is already
|
|
105
|
+
* safe). Returns false — fail closed — for an unrecognized value shape (logged,
|
|
106
|
+
* since a shape this plugin cannot read refuses every such booking), a probe
|
|
107
|
+
* that finds no matching tenant, or any error while probing.
|
|
108
|
+
*
|
|
109
|
+
* A `hasMany` tenant field is supported: the value may be an array, and EVERY
|
|
110
|
+
* id in it is probed.
|
|
111
|
+
*/
|
|
112
|
+
export declare function callerMayUseTenant(args: {
|
|
113
|
+
config: Pick<ResolvedReservationPluginConfig, 'multiTenant' | 'slugs'>;
|
|
114
|
+
data: Record<string, unknown>;
|
|
115
|
+
req: PayloadRequest;
|
|
116
|
+
}): Promise<boolean>;
|
|
41
117
|
export {};
|