Skip to content

v3.0.0

Choose a tag to compare

@github-actions github-actions released this 30 Jul 13:14

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/book calls for one quantity: 1 slot produced 10 confirmed reservations; 8 simultaneous calls against a quantity: 3 resource produced 8. A new hidden bookingLock text field on Resources, written by a new acquireBookingLock beforeChange hook 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 bookingLock column to the resources table 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 a quantity: 3 resource recovers only 1 of 3 under a burst; retryOnWriteConflict recovers 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 a quantity: 5 resource granted 3 and returned 37 clean 409s. Capacity is never exceeded; it can be under-filled.
    • SQLite requires transactionOptions set 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/drizzle limitation: the driver's structured error code is discarded before this plugin's retry logic ever sees it) — and POST /api/reserve/book returns a raw HTTP 500 under SQLite contention rather than the clean 409 MongoDB 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 the updatedAt of every Resource that booking claims — including the pools pulled in by a Service's requiredResources. Nothing reads the lock value, but if you sort Resources by updatedAt, 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.
  • Deleting a Service or Resource that is still referenced now fails deliberately with an actionable 400 naming 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-actionable 23502/SQLITE_CONSTRAINT_NOTNULL constraint error rather than this message. Set active: false instead of deleting to retire a Service or Resource while keeping its booking (or schedule) history intact.

  • A reservation's top-level resource is now conflict-checked even when the request also supplies an explicit items[] that never names it. resolveReservationItems (exported from the package root) always synthesizes an extra item from the top-level resource/startTime/endTime — flagged fromParent: true on the returned ResolvedItem — unless an items[] 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 while items[] names only other resources) are now rejected. ResolvedItem gained the optional fromParent?: boolean field, and direct callers of resolveReservationItems should 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. reservationOccupancies runs resolveReservationItems over stored reservations, so the synthesized parent is resolved for existing rows too — a resource that getAvailableSlots/checkAvailability reported as free before the upgrade can become busy afterwards with no write occurring. The effect is largest in the calculateEndTime-spanned shape (top-level resource C with items[] 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-level resource absent from their own items[] before upgrading a production install.

  • A partial update patch that changes only startTime on a multi-resource booking — landing later than the reservation's currently-stored endTime, with items[]/endTime left untouched — now hard-errors (endTime must be after startTime) instead of being silently absorbed as a no-op. This closes a gap where resolveReservationItems would 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 by checkAvailability/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 resolveReservationItems itself, which is a documented public export (import { resolveReservationItems } from 'payload-reserve'). Called directly with an inverted top-level window it now throws a ValidationError where 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 validate endTime > startTime upstream. A second argument is available for the read-path behaviour: resolveReservationItems(data, { lenient: true }) skips the phantom-item synthesis instead of throwing, which is what reservationOccupancies uses so a pre-existing malformed row cannot break an availability read.
  • /api/reservation-customer-search, /api/reserve/resource-availability, /api/reserve/cancel, and /api/reserve/effective-timezone now 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 — a findByID probe 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/cancelonly 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's update: adminOnly would 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/book is deliberately NOT covered. Its payload.create stays privileged for every caller, exactly as in every earlier release: anonymous guest bookings have no req.user to authorize, and under resourceOwnerMode a create: adminOnly rule would block an authenticated customer booking for themselves. Its security boundary is the access-checked tenant-membership probe described under Fixed, below — not overrideAccess, which cannot constrain a create at all (Payload's create access check only inspects truthiness and never applies a returned Where the way read/update/delete do). Consequently a consumer's own access.reservations.create, and any field-level create access added through collectionOverrides.reservations, are still not applied to bookings made through this endpoint — put such a rule in a beforeChange hook or in hooks.beforeBookingCreate, both of which do run.

    Plain installs (no resourceOwnerMode, no multiTenant, no custom access overrides) 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 userCollection defines its own restrictive access.read (e.g. scoping a user to their own record), /api/reservation-customer-search now 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 own access.read.
    • Under resourceOwnerMode, a staff user (a role in staffRoles but not adminRoles) can now only pull /api/reserve/resource-availability for their own resource, matching the restriction the Resources collection already applies elsewhere. Add the role to adminRoles to keep the wider view for that user.
  • active: false on a Service or Resource is now enforced at booking time. Creating a reservation against an inactive service/resource (or any multi-resource items[] entry referencing one) is rejected, as is updating a reservation to newly reference one or to reschedule it — any change to startTime, endTime, service, resource, items, or guestCount re-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. Set enforceActive: false in the plugin config to restore the previous behaviour, where active was purely a display flag with no effect on booking or availability.

  • getAvailableSlots now returns { reason?, slots } instead of a bare Slot[] array. Direct importers must update destructuring, e.g. const { slots } = await getAvailableSlots(...). The EmptyReason type is exported alongside it (from both src/index.ts and src/services/index.ts) and describes why slots came back empty (e.g. 'service_inactive', 'resource_inactive', 'no_windows', 'window_too_short', 'all_slots_taken').

  • peerDependencies now require payload ^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 a reservation-holds collection and two new endpoints, POST /api/reserve/hold and POST /api/reserve/hold/release; POST /api/reserve/book accepts an optional holdToken to 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 token is a bearer secret, so the reservation-holds collection is closed to the REST API on all four operations, read included — GET /api/reservation-holds is 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 enable slotHolds under multiTenant, add the holds slug to the multi-tenant plugin's own collections option; 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/slots and /api/reserve/resource-availability all 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 the resources, schedules and reservations REST 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 through checkAvailability, which does count holds.
    • POST /api/reserve/hold maps outcomes to distinct statuses rather than collapsing every failure into a 409 carrying an internal error message: 409 for genuine unavailability (slot_taken, service_inactive), 409 { retryable: true } when lock contention outlived the retry budget, 404 for service_not_found/resource_not_found, 400 for malformed input — including an unparseable endTime or a guestCount below 1, both of which previously surfaced as a misleading 409 slot_taken or an outright 500 — and a 500 only for an actual server-side failure. No internal error text is echoed to this (unauthenticated) endpoint.
  • /api/reserve/book and /api/reserve/cancel now map a surviving write conflict to a clean HTTP 409 { 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, plus customers in standalone mode) is not tenant-scoped — which happens when payloadReserve() is listed after multiTenantPlugin() in the plugins array, or when its slugs were left out of multi-tenant's own collections option. payloadReserve() must run before multiTenantPlugin() 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's tenants membership array. Neither is exact — the membership array is absent under tenantsArrayField.includeDefaultField: false and 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 resources field — a join over Resources.services — listing which resources perform that service. Resources.services remains the only editable side; this is purely a reverse view for the admin UI and API reads.

  • Empty availability responses from /api/reserve/availability and /api/reserve/slots now carry a machine-readable reason field 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 as all_slots_taken.

  • The plugin logs a warning at init when the Services resources join is skipped because a collectionOverrides.resources override removed, renamed, or nested the services field — previously the field simply went missing with no explanation.

  • The dashboard widget's five aggregate reads are now access-checked (overrideAccess: false, with disableErrors: true so an access denial renders zeros rather than throwing out of the React Server Component), extracted into the testable fetchDashboardStats helper; getEffectiveTenantTimezone gained an optional req and access-checks the tenant-doc read whenever one is passed (all current call sites pass one).

Fixed

  • POST /api/reserve/book no longer lets an authenticated multiTenant caller create a reservation in a tenant they aren't a member of by supplying an explicit tenant in the request body. An access-checked membership probe (callerMayUseTenant) now runs for every authenticated caller — it, not overrideAccess, is this endpoint's security boundary, because Payload's create access check only inspects truthiness and never applies a returned Where the way read/update/delete do, so no overrideAccess setting can constrain which tenant is written to. A hasMany tenant 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 — no userCollection — 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 hasMany tenant field no longer breaks booking entirely under multiTenant. Such an install sends tenant: [id]; the membership probe could not read an array value and failed closed, so every authenticated booking carrying an explicit tenant was refused with a 403 — 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 req inside a transaction. A MongoDB ClientSession cannot carry concurrent operations inside a transaction, and Payload's read operations call killTransaction from their own catch even though they never opened one — so when two collided, the loser rolled back and cleared the transaction the enclosing create/delete owned, and the survivor failed with NoSuchTransaction ("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 actionable 400, and — because NoSuchTransaction carries MongoDB's TransientTransactionError label — a booking could burn its whole retry budget and return 409 { retryable: true } for a genuinely free slot under load. Three loops are now sequential: the delete guard's reference counts, and checkAvailability'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 req could be left permanently poisoned after a failed beginTransaction. Payload's initTransaction stores the pending promise on req.transactionID before awaiting it, and killTransaction's cleanup guard skips promises — so if beginTransaction rejects, that rejected promise stays on the req and every later Payload operation on it short-circuits and re-throws the original error without ever reaching the database. retryOnWriteConflict now 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/drizzle strips 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 under multiTenant) 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 startTime field is still a datetime-local input 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 as 00:00. Saving as-is stores the right time — a strict improvement over the previous behaviour — but an admin who "corrects" the displayed value back to 10:00 reintroduces 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 bookingLock text column to the resources table 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 use slotHolds.
  • SQLite consumers must add transactionOptions to sqliteAdapter(...) 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, run pnpm dev:generate-types (or your own types workflow) after upgrading to pick up the new reservation-holds collection, and apply a migration for it on Postgres/SQLite the same way you would for any other new collection. Under multiTenant, add its slug to the multi-tenant plugin's collections option at the same time.

Audit before upgrading

  • Audit existing reservations whose top-level resource is not named in their own items[]. 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: false to retire it instead.
  • Audit any update flow that patches only startTime on a multi-resource booking without also updating items[]/endTime. A patch that lands the new startTime after the stale, already-stored endTime is now rejected rather than silently doing nothing.

Code changes

  • If you call resolveReservationItems directly, 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 synthesized fromParent entry if anything depends on the returned array's exact length.
  • If you import getAvailableSlots directly, update destructuring to const { 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.services already appends a field named resources, 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.resources removes, renames, or nests the services field inside a named group or tab, the new Services resources join 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-availability to view resources they don't own, add their role to adminRoles.
  • If your userCollection's access.read is more restrictive than "any authenticated user," expect /api/reservation-customer-search results to narrow to match it.
  • If you use multiTenant, verify payloadReserve() precedes multiTenantPlugin() in your plugins array and that this plugin's collection slugs — including customers in standalone mode — are listed in multi-tenant's collections option. The plugin will now warn at boot if they aren't. Leaving customers out means /api/reservation-customer-search keeps returning every tenant's customers, because there is no tenant field for it to filter on.
  • If you rely on access.reservations.create or field-level create access to gate bookings, move that rule into a beforeChange hook or hooks.beforeBookingCreate/api/reserve/book's create is privileged and does not apply collection create access (unchanged from earlier releases).