Files
2026-05-27 23:29:53 +01:00

5.9 KiB

Design — @suaveplan/booking

Architecture overview

The booking engine is a pipeline composed of six pluggable stages, each owned by an isolated feature module:

Availability  →  Conflict detection  →  Pricing  →  Rules  →  State machine  →  Events

Every stage is registry-driven (registerDetector, registerAvailabilityStrategy) so callers can extend behaviour without forking. The pipeline orchestrator is BookingEngine, which holds in-memory Map<id, Bookable | Booking | BookingHold> registries plus an in-package Hooks<BookingHookMap> instance and an EventBus.

File layout

src/
  index.ts                                          — explicit named root barrel
  error-codes/
    error-codes.ts | error-codes.test.ts | error-codes.md | index.ts
  hooks/
    hooks.ts | hooks.test.ts | hooks.md | hooks.types.ts | index.ts
  utils/
    utils.ts | utils.test.ts | utils.md | index.ts
  constants/
    constants.ts | constants.md | index.ts
  types/
    bookable.types.ts (+ .md)
    booking.types.ts (+ .md)
    booking-hooks.ts (+ .md)
    capacity.types.ts (+ .md)
    config.types.ts (+ .md)
    events.types.ts (+ .md)
    layout.types.ts (+ .md)
    pricing.types.ts (+ .md)
    rules.types.ts (+ .md)
    temporal.types.ts (+ .md)
  temporal/
    windows/(windows.ts + test + md + index)
    recurrence/(…)
    slots/(…)
  conflict/
    detector/(detector.ts + .types.ts + test + md + index)
    exact-match/(…) | range-overlap/(…) | slot-capacity/(…) | provider-overlap/(…)
  pricing/
    calculator/(calculator.ts + .types.ts + test + md + index)
    seasonal/(…) | dynamic/(…)
  rules/
    evaluator/(evaluator.ts + .types.ts + test + md + index)
    conditions/(…)
  availability/
    resolver/(resolver.ts + .types.ts + test + md + index)
    strategies/(strategies.ts + .types.ts + test + md + index)
  domains/
    shared.ts | shared.test.ts | shared.md | index.ts
    seats/(seats.ts + .types.ts + test + md + index)
    accommodation/(…) | appointments/(…) | resources/(…)
    activities/(…) | transport/(…)
  schemas/
    booking.schema.ts | booking.schema.test.ts | booking.schema.md | index.ts
    config.schema.ts | config.schema.test.ts | config.schema.md
  core/
    engine/(engine.ts + .types.ts + test + md + index)
    state-machine/(state-machine.ts + test + md + index)
    event-bus/(event-bus.ts + test + md + index)
  conformance.spec-conformance.test.ts             — barrel + subpath conformance
tests/
  setup/polyfills.ts

Key decisions

1. In-package Hooks<TMap> (vs. depending on @suaveplan/events/hooks)

Genesis @suaveplan/events does not yet expose /hooks or /hooks.types. Rather than block this migration on a separate events-hooks change, the upstream Hooks impl is re-implemented in-package under src/hooks/ with the same public surface. The BookingHookMap is defined locally so Hooks<BookingHookMap> works without external types. When events ships its hooks subpath, the in-package impl can be removed in a follow-up; the contract preserved here matches the upstream surface exactly so consumers do not need to change.

2. Typed errors over plain throw new Error

Every boundary that previously raised a plain Error now raises a BookingError extending SuaveplanError<BookingErrorCode>. Codes are stable namespaced strings ("BOOKING.ENGINE_NOT_CONFIGURED"). Optional discriminants — bookingId, holdId, mode, strategy — are attached without polluting the base context map; consumers can read them on the typed instance after a narrow.

The Hooks sync-path console.warn (upstream behaviour when a handler returns a Promise) is upgraded to a BookingError({ code: BOOKING.HOOK_RETURNED_PROMISE_IN_SYNC }). Universal-tier libraries should not log to stdout; the typed error lets callers decide how to handle the bug.

3. Module-level mutable registries preserved

Upstream uses module-level Map<> registries for conflict detectors and availability strategies. The migration preserves this for parity (downstream callers depend on the global mutation). A future change can pivot to scoped registries; that is an out-of-scope behaviour change.

4. Determinism is upstream's responsibility

Upstream uses Date.now() and Math.random() inside generateId for in-memory id generation. The migration preserves both. Tests assert structural properties (prefix, uniqueness across N calls) rather than exact strings; a future change can thread an injectable IdGenerator from @suaveplan/types once consumers need deterministic ids.

5. Subpath exports

Each feature module gets a subpath in package.json exports so consumers like @suaveplan/r3f-booking can import only @suaveplan/booking/temporal/windows/windows and skip the engine + schemas + rule evaluator. Tree-shaking is verified at build time (preserveModules + sideEffects:false).

Public surface inventory

  • Engine + types (createBookingEngine, BookingEngine, BookingEngineInterface, AvailabilityQuery, AvailabilityResult, ConflictResult, PriceCalculationRequest)
  • State machine (StateMachine, createStateMachine, TransitionRequest, TransitionResult)
  • Event bus (EventBus, createEventBus)
  • Hooks (Hooks, HookMap, HookContext, HookHandler, HookHandlerOptions, HookResult, RegisteredHook, Hookable)
  • Temporal (windows: 9 fns; recurrence: 3 fns; slots: 4 fns)
  • Conflict (detector: 4 fns + types; 4 built-ins)
  • Pricing (calculator: 5 fns + types; seasonal: 4 fns; dynamic: 4 fns + types)
  • Rules (evaluator: 2 fns; conditions: 8 factories)
  • Availability (resolver: 4 fns + types; strategies: 3 built-ins + SlotAvailability type)
  • Domains (base + 6 presets + 6 meta types)
  • Schemas (6 entity + 1 config + 5 validate/safe-validate helpers)
  • Errors (BOOKING_CODES, BookingError, BookingErrorCode, BookingErrorOptions)
  • Utils (5 helpers) + Constants (15 entries)
  • All upstream type names preserved verbatim (50+)