v3.0.0
This release exists because a test proved the plugin never prevented concurrent
double-booking at all. Read the first breaking change and the migration notes
before upgrading a production install — two of the changes can alter the
availability of reservations already in your database, with no write
occurring.
Breaking
-
Concurrent bookings were not prevented at all before this release. Measured on MongoDB: 10 simultaneous
POST /api/reserve/bookcalls for onequantity: 1slot produced 10 confirmed reservations; 8 simultaneous calls against aquantity: 3resource produced 8. A new hiddenbookingLocktext field on Resources, written by a newacquireBookingLockbeforeChangehook inside the booking's own transaction, now serializes concurrent claims of the same resource so the database — not the plugin — forces the losers to wait or abort.- Schema addition — Postgres/SQLite consumers need a migration to add the
bookingLockcolumn to theresourcestable before upgrading (MongoDB is schemaless and needs nothing). - Retry is required on MongoDB and does nothing on Postgres. MongoDB's loser aborts immediately (
WriteConflict, code 112) rather than waiting, so without retry aquantity: 3resource recovers only 1 of 3 under a burst;retryOnWriteConflictrecovers the full 3 of 3. Postgres's loser blocks until the winner commits and then proceeds through the normal conflict check on the merits, so retry never fires there — measured 3 of 3 recovered with no retry needed. The retry budget is finite (5 attempts), so recovery is not a guarantee at extreme contention: a measured 40-way burst against aquantity: 5resource granted 3 and returned 37 clean409s. Capacity is never exceeded; it can be under-filled. - SQLite requires
transactionOptionsset on the adapter, or the lock is a silent no-op and bookings double-book exactly as before this release. Configured correctly, SQLite does not double-book, but capacity is capped at 1 of 3 even with retry attempted — for bookings and for slot holds, which share the same lock and the same retry path (an upstream@payloadcms/drizzlelimitation: the driver's structured error code is discarded before this plugin's retry logic ever sees it) — andPOST /api/reserve/bookreturns a raw HTTP 500 under SQLite contention rather than the clean409MongoDB and Postgres get. See README's "Concurrent booking: database adapter support" for the full measured, per-adapter matrix and consumer guidance. - Side effect worth knowing about: every booking now touches each claimed Resource's
updatedAt. Taking the lock is a write to the Resource document, so on MongoDB (where the collection carries timestamps) creating, rescheduling, or cancelling a reservation bumps theupdatedAtof every Resource that booking claims — including the pools pulled in by a Service'srequiredResources. Nothing reads the lock value, but if you sort Resources byupdatedAt, cache-key on it, drive an incremental sync from it, or surface "last modified" in your admin UI, expect churn proportional to booking volume rather than to actual Resource edits.
- Schema addition — Postgres/SQLite consumers need a migration to add the
-
Deleting a Service or Resource that is still referenced now fails deliberately with an actionable
400naming what's blocking, e.g.Cannot delete this resource: 2 reservations and 1 schedule still reference it. Uncheck "active" to retire it instead — that stops new bookings while keeping existing ones intact.Previously MongoDB allowed the delete silently, leaving the referencing document pointing at nothing; Postgres/SQLite always failed the same delete, but with a raw, un-actionable23502/SQLITE_CONSTRAINT_NOTNULLconstraint error rather than this message. Setactive: falseinstead of deleting to retire a Service or Resource while keeping its booking (or schedule) history intact. -
A reservation's top-level
resourceis now conflict-checked even when the request also supplies an explicititems[]that never names it.resolveReservationItems(exported from the package root) always synthesizes an extra item from the top-levelresource/startTime/endTime— flaggedfromParent: trueon the returnedResolvedItem— unless anitems[]entry for that resource can already be shown to cover the same window. Bookings that previously slipped past conflict detection this way (top-level resource A booked whileitems[]names only other resources) are now rejected.ResolvedItemgained the optionalfromParent?: booleanfield, and direct callers ofresolveReservationItemsshould expect the returned array to sometimes contain one more entry than before.This changes read-side availability for rows already in your database, not just future writes.
reservationOccupanciesrunsresolveReservationItemsover stored reservations, so the synthesized parent is resolved for existing rows too — a resource thatgetAvailableSlots/checkAvailabilityreported as free before the upgrade can become busy afterwards with no write occurring. The effect is largest in thecalculateEndTime-spanned shape (top-level resource C withitems[]naming only A and B): the parent's window spans earliest-start→latest-end, so C can be blocked for hours. This is the correct semantics — the row genuinely does claim C — but slots can disappear the moment you deploy. Check for reservations with a top-levelresourceabsent from their ownitems[]before upgrading a production install. -
A partial update patch that changes only
startTimeon a multi-resource booking — landing later than the reservation's currently-storedendTime, withitems[]/endTimeleft untouched — now hard-errors (endTime must be after startTime) instead of being silently absorbed as a no-op. This closes a gap whereresolveReservationItemswould previously synthesize a harmless-looking phantom item from an inverted top-level window rather than rejecting the malformed input at the source. The read path (reservationOccupancies, used bycheckAvailability/getAvailableSlots) tolerates a pre-existing malformed row leniently — it will not newly crash an availability check because of a row that predates this release — but the same shape is now rejected on write.- This also changes
resolveReservationItemsitself, which is a documented public export (import { resolveReservationItems } from 'payload-reserve'). Called directly with an inverted top-level window it now throws aValidationErrorwhere it previously returned an array containing a synthesized phantom item. If you call it outside the plugin's own hooks — to pre-validate a booking payload, to compute occupancy in your own reporting, or anywhere else — wrap it or validateendTime > startTimeupstream. A second argument is available for the read-path behaviour:resolveReservationItems(data, { lenient: true })skips the phantom-item synthesis instead of throwing, which is whatreservationOccupanciesuses so a pre-existing malformed row cannot break an availability read.
- This also changes
-
/api/reservation-customer-search,/api/reserve/resource-availability,/api/reserve/cancel, and/api/reserve/effective-timezonenow enforce the underlying collection's access control (overrideAccess: false+req) instead of reading privileged. This is per-path, not a blanket flip — each endpoint gates the request with one explicit access-checked call and keeps its derived reads privileged so it can still assemble a complete answer:/api/reservation-customer-search— the customer query itself delegates./api/reserve/resource-availability— afindByIDprobe of the requested resource delegates (404 on denial). The reads that build the grid stay privileged, deliberately: a conflicting booking you cannot see is a double-booking./api/reserve/cancel— only the privileged-non-owner update delegates. The reservation read, and the update on the owner and guest-token paths, stay privileged by design — for a guest the cancellation token is the authorization, and owner-mode'supdate: adminOnlywould otherwise block a customer cancelling their own booking. Ownership/token are checked in the endpoint before either path is taken./api/reserve/effective-timezone— the tenant-document read delegates, falling back to the global zone on denial.POST /api/reserve/bookis deliberately NOT covered. Itspayload.createstays privileged for every caller, exactly as in every earlier release: anonymous guest bookings have noreq.userto authorize, and underresourceOwnerModeacreate: adminOnlyrule would block an authenticated customer booking for themselves. Its security boundary is the access-checked tenant-membership probe described under Fixed, below — notoverrideAccess, which cannot constrain acreateat all (Payload's create access check only inspects truthiness and never applies a returnedWherethe way read/update/delete do). Consequently a consumer's ownaccess.reservations.create, and any field-levelcreateaccess added throughcollectionOverrides.reservations, are still not applied to bookings made through this endpoint — put such a rule in abeforeChangehook or inhooks.beforeBookingCreate, both of which do run.
Plain installs (no
resourceOwnerMode, nomultiTenant, no customaccessoverrides) are unaffected — Payload's own default access is({ req: { user } }) => Boolean(user), so an authenticated user still passes. Two cases now behave differently:- If your
userCollectiondefines its own restrictiveaccess.read(e.g. scoping a user to their own record),/api/reservation-customer-searchnow respects it — the endpoint no longer out-permissions the collection it reads from. Customer-search results narrow accordingly; the fix, if unwanted, is in that collection's ownaccess.read. - Under
resourceOwnerMode, a staff user (a role instaffRolesbut notadminRoles) can now only pull/api/reserve/resource-availabilityfor their own resource, matching the restriction the Resources collection already applies elsewhere. Add the role toadminRolesto keep the wider view for that user.
-
active: falseon a Service or Resource is now enforced at booking time. Creating a reservation against an inactive service/resource (or any multi-resourceitems[]entry referencing one) is rejected, as is updating a reservation to newly reference one or to reschedule it — any change tostartTime,endTime,service,resource,items, orguestCountre-checks every reference, so a booking cannot be moved onto a resource that availability would refuse to offer. Inactive services/resources are also excluded from availability. Edits that do not touch scheduling are unaffected: an existing booking stays confirmable, cancellable, and otherwise editable after its service or resource is deactivated later. SetenforceActive: falsein the plugin config to restore the previous behaviour, whereactivewas purely a display flag with no effect on booking or availability. -
getAvailableSlotsnow returns{ reason?, slots }instead of a bareSlot[]array. Direct importers must update destructuring, e.g.const { slots } = await getAvailableSlots(...). TheEmptyReasontype is exported alongside it (from bothsrc/index.tsandsrc/services/index.ts) and describes whyslotscame back empty (e.g.'service_inactive','resource_inactive','no_windows','window_too_short','all_slots_taken'). -
peerDependenciesnow requirepayload ^3.86.0,@payloadcms/ui ^3.86.0, and@payloadcms/translations ^3.86.0(was^3.79.0). Upgrade Payload before upgrading this plugin.
Added
-
Opt-in
slotHolds— short-lived claims on a slot taken while a customer completes checkout, so it can't be booked out from under them. Enabling it (slotHolds: { enabled: true, ttlMinutes?: number }) adds areservation-holdscollection and two new endpoints,POST /api/reserve/holdandPOST /api/reserve/hold/release;POST /api/reserve/bookaccepts an optionalholdTokento convert a hold into a booking. Left unset (the default), no collection is added, no new endpoints are registered, and availability behaviour is byte-identical to before.- A hold's
tokenis a bearer secret, so thereservation-holdscollection is closed to the REST API on all four operations,readincluded —GET /api/reservation-holdsis denied for every caller including admins, since reading a live token is enough to release someone else's hold or book their slot. If you enableslotHoldsundermultiTenant, add the holds slug to the multi-tenant plugin's owncollectionsoption; the boot diagnostic now warns when you don't. - Held slots are excluded from the read path too, not only from bookings:
/api/reserve/availability,/api/reserve/slotsand/api/reserve/resource-availabilityall treat an unexpired hold as busy, so no customer-facing path offers a slot the booking endpoint will then refuse. That covers the reservation form's slot picker and the admin Calendar view. Known limitation: the admin Availability grid (AvailabilityOverview, at/reservation-availability) does not — it queries theresources,schedulesandreservationsREST endpoints directly and computes its grid client-side, so a held slot still reads as free there. Display-only and admin-only: it cannot cause a wrong write, because every write goes throughcheckAvailability, which does count holds. POST /api/reserve/holdmaps outcomes to distinct statuses rather than collapsing every failure into a409carrying an internal error message:409for genuine unavailability (slot_taken,service_inactive),409 { retryable: true }when lock contention outlived the retry budget,404forservice_not_found/resource_not_found,400for malformed input — including an unparseableendTimeor aguestCountbelow 1, both of which previously surfaced as a misleading409 slot_takenor an outright500— and a500only for an actual server-side failure. No internal error text is echoed to this (unauthenticated) endpoint.
- A hold's
-
/api/reserve/bookand/api/reserve/cancelnow map a surviving write conflict to a clean HTTP409 { retryable: true }instead of an unhandled 500 (MongoDB and Postgres; not SQLite, per the concurrency notes above). -
New boot warning when the configured database gives Payload no transactions at all (a standalone, non-replica-set MongoDB, or SQLite without
transactionOptions) — the lock silently protects nothing in that configuration, and there is no other runtime signal. -
New boot warning when multi-tenancy appears to be enabled but one of this plugin's own collections (
reservations,resources,schedules,services, pluscustomersin standalone mode) is not tenant-scoped — which happens whenpayloadReserve()is listed aftermultiTenantPlugin()in thepluginsarray, or when its slugs were left out of multi-tenant's owncollectionsoption.payloadReserve()must run beforemultiTenantPlugin()for tenant-field detection to work at all. Detection is a heuristic with two signals, either of which arms the check: some collection carries a top-level tenant field, or an auth collection carries multi-tenant'stenantsmembership array. Neither is exact — the membership array is absent undertenantsArrayField.includeDefaultField: falseand its name is configurable — so the warning may occasionally fire on a config that merely looks tenant-shaped. It is only ever a warning and never blocks boot. -
Services now show a read-only
resourcesfield — a join overResources.services— listing which resources perform that service.Resources.servicesremains the only editable side; this is purely a reverse view for the admin UI and API reads. -
Empty availability responses from
/api/reserve/availabilityand/api/reserve/slotsnow carry a machine-readablereasonfield explaining why no slots were returned. -
window_too_short— availability now distinguishes "every shift is shorter than the service duration" from "the day is fully booked", instead of reporting both asall_slots_taken. -
The plugin logs a warning at init when the Services
resourcesjoin is skipped because acollectionOverrides.resourcesoverride removed, renamed, or nested theservicesfield — previously the field simply went missing with no explanation. -
The dashboard widget's five aggregate reads are now access-checked (
overrideAccess: false, withdisableErrors: trueso an access denial renders zeros rather than throwing out of the React Server Component), extracted into the testablefetchDashboardStatshelper;getEffectiveTenantTimezonegained an optionalreqand access-checks the tenant-doc read whenever one is passed (all current call sites pass one).
Fixed
-
POST /api/reserve/bookno longer lets an authenticatedmultiTenantcaller create a reservation in a tenant they aren't a member of by supplying an explicittenantin the request body. An access-checked membership probe (callerMayUseTenant) now runs for every authenticated caller — it, notoverrideAccess, is this endpoint's security boundary, because Payload'screateaccess check only inspects truthiness and never applies a returnedWherethe way read/update/delete do, so nooverrideAccesssetting can constrain which tenant is written to. AhasManytenant field is supported (every id in the array is probed), and a tenant value in a shape the plugin cannot read is refused with a logged warning rather than silently. (In standalone mode — nouserCollection— this probe cannot verify a customer's actual tenant membership, since customers authenticate against a collection multi-tenant never wraps; the plugin now warns about this specific configuration at boot.) -
A
hasManytenant field no longer breaks booking entirely undermultiTenant. Such an install sendstenant: [id]; the membership probe could not read an array value and failed closed, so every authenticated booking carrying an explicit tenant was refused with a403— fail-closed, but a total booking outage with no diagnostic. Every id in the array is now probed, an empty array is treated as "no tenant supplied", and a value in a shape the plugin genuinely cannot read still refuses but logs a warning naming the shape. -
The plugin no longer issues concurrent Payload Local API reads on one
reqinside a transaction. A MongoDBClientSessioncannot carry concurrent operations inside a transaction, and Payload's read operations callkillTransactionfrom their own catch even though they never opened one — so when two collided, the loser rolled back and cleared the transaction the enclosingcreate/deleteowned, and the survivor failed withNoSuchTransaction("transaction number N does not match any in-progress transactions"). Symptoms in production: deleting a referenced Service or Resource could fail with that opaque error instead of the actionable400, and — becauseNoSuchTransactioncarries MongoDB'sTransientTransactionErrorlabel — a booking could burn its whole retry budget and return409 { retryable: true }for a genuinely free slot under load. Three loops are now sequential: the delete guard's reference counts, andcheckAvailability's reservation- and hold-occupancy resolution. Buffers are cached per service, so this is at most one read per distinct neighbouring service either way. -
A
reqcould be left permanently poisoned after a failedbeginTransaction. Payload'sinitTransactionstores the pending promise onreq.transactionIDbefore awaiting it, andkillTransaction's cleanup guard skips promises — so ifbeginTransactionrejects, that rejected promise stays on thereqand every later Payload operation on it short-circuits and re-throws the original error without ever reaching the database.retryOnWriteConflictnow clears a leftover between attempts (never one the caller already owned), and the slot-hold expiry sweep — documented as unable to fail a hold — clears it in its own catch, since it swallows the error mid-attempt where the retry wrapper cannot see it. This is unrelated to SQLite's separate "raising the retry budget changes nothing" behaviour, which has its own cause:@payloadcms/drizzlestrips the driver's structured error code, so the conflict is never recognised as transient and the retry loop exits on its first attempt. -
The admin Calendar's grid instants, click targets, day-key sequences, and month/week header labels now resolve in the plugin's business timezone (
timezone, or the selected tenant's zone undermultiTenant) instead of the browser's local timezone. Previously, viewing the calendar from a different timezone than the business's could make clicking a displayed slot book a different wall-clock hour than the one shown.Known gap: the reservation drawer's
startTimefield is still adatetime-localinput with no business-timezone awareness, so it renders in the browser's zone. Clicking the "10:00" row of an Auckland-business calendar from Paris now pre-fills the correct instant, but that instant displays as00:00. Saving as-is stores the right time — a strict improvement over the previous behaviour — but an admin who "corrects" the displayed value back to10:00reintroduces the original bug. Treat the calendar grid, not the drawer field, as the source of truth for the intended slot.
Documented, not changed
- Every plugin lifecycle hook this plugin fires (
afterBookingCreate,afterBookingConfirm,afterBookingCancel,afterStatusChange) runs inside the write's own database transaction, never after it commits — verified directly against Payload's own operation code. Payload exposes no post-commit hook point, so a host whose side effect must never fire for an uncommitted booking should make it idempotent and reconcile, rather than relying on hook-vs-commit ordering. See README's "Hook timing" section.
Migration notes
Database schema
- Postgres and SQLite consumers must add the new
bookingLocktext column to theresourcestable before upgrading (nullable, no default required — the value is written but never read). If your dev/staging setup auto-syncs schema (push: true), this happens for you there; production Postgres needs a real migration. This is unrelated to whether you useslotHolds. - SQLite consumers must add
transactionOptionstosqliteAdapter(...)or the new booking lock silently protects nothing — concurrent bookings will double-book exactly as they did before this release, with no error. See README for the full adapter-support matrix, including SQLite's separate, accepted capacity-recovery and 500-vs-409 limitations. - If you enable
slotHolds, runpnpm dev:generate-types(or your own types workflow) after upgrading to pick up the newreservation-holdscollection, and apply a migration for it on Postgres/SQLite the same way you would for any other new collection. UndermultiTenant, add its slug to the multi-tenant plugin'scollectionsoption at the same time.
Audit before upgrading
- Audit existing reservations whose top-level
resourceis not named in their ownitems[]. Those rows now occupy that resource on the read side too, so previously-bookable slots can vanish without any write. - Check for any script, seed, or admin workflow that deletes a Service or Resource still referenced by a reservation or schedule — that delete will now fail. Use
active: falseto retire it instead. - Audit any update flow that patches only
startTimeon a multi-resource booking without also updatingitems[]/endTime. A patch that lands the newstartTimeafter the stale, already-storedendTimeis now rejected rather than silently doing nothing.
Code changes
- If you call
resolveReservationItemsdirectly, note it now throws on an inverted top-level window instead of returning a phantom item — pass{ lenient: true }for the previous non-throwing shape, or validate upstream. Also account for the new synthesizedfromParententry if anything depends on the returned array's exact length. - If you import
getAvailableSlotsdirectly, update destructuring toconst { slots } = await getAvailableSlots(...). - If anything you own keys off a Resource's
updatedAt— sorting, caching, incremental sync, a "last modified" column — expect it to change on every booking now, not only on real Resource edits. - If your
collectionOverrides.servicesalready appends a field namedresources, rename it — it now collides with the built-in join. (The name used in the docs,referencedResources, is a different name and is unaffected.) - If your
collectionOverrides.resourcesremoves, renames, or nests theservicesfield inside a named group or tab, the new Servicesresourcesjoin is silently skipped rather than added — the app still boots, the field simply doesn't appear.
Access control and multi-tenancy
- If staff (non-admin, per
resourceOwnerMode.adminRoles) users rely on/api/reserve/resource-availabilityto view resources they don't own, add their role toadminRoles. - If your
userCollection'saccess.readis more restrictive than "any authenticated user," expect/api/reservation-customer-searchresults to narrow to match it. - If you use
multiTenant, verifypayloadReserve()precedesmultiTenantPlugin()in yourpluginsarray and that this plugin's collection slugs — includingcustomersin standalone mode — are listed in multi-tenant'scollectionsoption. The plugin will now warn at boot if they aren't. Leavingcustomersout means/api/reservation-customer-searchkeeps returning every tenant's customers, because there is no tenant field for it to filter on. - If you rely on
access.reservations.createor field-level create access to gate bookings, move that rule into abeforeChangehook orhooks.beforeBookingCreate—/api/reserve/book's create is privileged and does not apply collection create access (unchanged from earlier releases).