Release 1.64.0

Release date: April 17, 2026

Release Notes - Seatmap Platform

Version 1.64.0 - 2026-04-17

Release Focus: Seat state outbox with two-layer state model and customer-defined hold types, plus editor save button reliability with atomic save orchestration.


What’s New

Two-layer seat state model with custom hold types

Seat state is now modeled as two orthogonal dimensions:

  • Lifecycle state – a closed enum controlling concurrency and availability. The existing ACTIVE, LOCKED, and SOLD states are joined by a new BLOCKED state for operationally unavailable seats (broken, obstructed view, capacity restricted).
  • Hold type – an open, customer-defined string carrying the business context of a hold: why it exists, who can release it, how long it lasts.

Two hold types ship as platform defaults:

  • CART – 15-minute TTL, customer-releasable, payment required. This is the default for the v2 booking /lock endpoint when no holdType is supplied, so existing integrations continue to work unchanged.
  • RESERVED – no TTL, admin-only release, no payment required.

Organizations define additional hold types in their existing config JSONB column using the standard organization and tenant config APIs. Tenant-level definitions are inherited by all organizations in the tenant, with per-organization overrides merged field-by-field. This follows the same three-level resolution pattern used for webhook configuration.

The renderer (@seatmap.pro/renderer) gains a new HoldIconOverlayRenderer WebGL atlas overlay that draws hold-type icons on held seats without allocating per-seat textures. The overlay ships disabled by default; host applications that want hold icons visible can enable it through the renderer options surface. The dedicated editor admin UI for defining hold types (SEAT-940) is part of the 1.64.0 Jira scope; operators should check their deployment surface to confirm the admin UI is available before exposing custom hold types to end users.

Full documentation: Seat States & Hold Types.

Optional holdType on the v2 booking API

The StateSelection payload accepts a new optional holdType string field:

{
  "sessionId": "abc123",
  "seats": [{ "id": 42 }],
  "holdType": "PARTNER_HOLD"
}

The booking service validates the hold type is known for the calling organization and that its validStates permit the target lifecycle transition. Unknown or mismatched hold types return 400 Bad Request. Omitting holdType on /lock defaults to CART. The /unlock and /revertsale endpoints ignore the field and clear whatever hold was attached when returning a seat to ACTIVE.

Double-booking fix

Every seat-level state transition is now a conditional SQL update that asserts the expected current state in its WHERE clause. Two concurrent requests locking the same seat can no longer both succeed – the second request’s update affects zero rows and the API returns false. The previous implementation relied on optimistic timing, which allowed rare double-locks under load. This closes SEAT-861 and is tracked under SEAT-941.

Transactional outbox replaces fire-and-forget telemetry

Every successful state transition now writes a row to a new seat_state_events table inside the same database transaction as the state change. A background drainer ships these events to the statistics service on a configurable interval. Key improvements:

  • At-least-once delivery – if the statistics service is unavailable, events stay in the outbox and are retried on the next drain cycle.
  • No more hot-path blocking – the booking request no longer waits on an HTTP call to the statistics service (the previous implementation had a 5-second implicit timeout on every lock, unlock, and sale).
  • On-prem compatibility preserved – setting seatmap.outbox.enabled=false disables the drainer. Outbox rows accumulate in the local database with no external calls, matching the previous silent-failure behavior.
  • Audit trail in Postgres – the seat_state_events table is an append-only log of every lifecycle transition with old/new state, hold type, session id, and timestamp.

The old MetricsServiceV2.changeState fire-and-forget path is marked @Deprecated(forRemoval = true) and will be removed in a future release. It remains in place during this release to cover the transition window.

v1 and v2 sale semantics unified

The v1 booking API previously deleted the seat_on_event row on /sale, while the v2 API updated the row in place. This made cross-customer analytics structurally broken – only organizations using v2 had SOLD rows in the database. Both paths now UPDATE state = 'SOLD' in place, leaving the row intact.

Integration impact: customers that query seat_on_event directly (outside the REST API) will start seeing SOLD rows for v1 sales where previously the row was deleted. No action is required for consumers that use the REST API.

updated_at column now honest

A JOOQ ExecuteListener was added to the booking service that auto-injects updated_at = now() on every UPDATE to seat_on_event or ga_on_event. Previously, UPDATEs used raw JOOQ that bypassed the Hibernate lifecycle hooks, so updated_at equalled created_at on every row in production (all 20.9M of them). Analytics views that filter by updated_at are now correct going forward. Historical rows keep their stale timestamps; there is no backfill.

Editor save button: continuous isDirty signal (SEAT-932)

The Save button in the editor now visually reflects whether there are unsaved changes.

Before: the button was always enabled regardless of whether anything had been edited. Assigning prices to seats or sections gave no visual cue that the schema needed saving — users had to guess.

Now: the button is disabled when the schema is clean, and shows an orange dot badge when there is unsaved work. The indicator is driven by a continuous dirty signal spanning both the schema slice (sections, rows, seats, venue shape, view box, underlay, shapes, bitmap) and the price-assignments slice.

Two-tier dirty detection:

  • A cheap per-slice counter bumps on any mutating action, so 99% of renders skip the expensive deep-equality check.
  • An authoritative selector runs the deep comparison only when the counter is non-zero, and short-circuits on the first real difference found. It correctly handles undo-to-pristine (counter > 0 but state actually matches the last saved version → selector returns false).

Fresh-load correctness: a handful of latent false-positives were fixed at the comparison level so schemas with null/undefined edge cases don’t appear dirty on load:

  • row.seatSpacing null symmetry (was comparing null to undefined, caused every schema with Russian-language rows to appear dirty).
  • IUnderlay numeric fields defaulted asymmetrically between the normalized model and the stored DTO.
  • IVenueShapeDTO and clientViewBox now normalized into loadedState at load time so the diff is apples-to-apples.
  • shapesJson uses a “first Fabric hydration round-trip is not a user edit” heuristic — the initial canonical re-serialization by Fabric.js is absorbed silently, but any subsequent user action bumps dirty.

Atomic save orchestrator (SEAT-932)

The editor save flow previously ran three separate backend calls (schema → price assignments → zone assignments) with an independent “Saved” toast firing after only the first. If price or zone assignments failed, users would see success while their pricing was silently lost.

Fixed: the save saga now orchestrates all three phases and shows the “Saved” toast only after everything succeeds. If any sub-phase fails, a partial-failure message names the failed part (prices, zones) and the dirty indicator stays on so the user knows to retry.

What integrators should know: no public API changes. The /api/seatmap/{sid}/, /api/events/{eid}/assignment/, and /api/schemas/{sid}/zonesAssignment/ endpoints all behave exactly as before — only the editor’s orchestration around them changed.

Out of scope (follow-ups): true backend atomicity (single transactional endpoint) is filed as a separate tech-debt task. If the schema save succeeds but prices fail, the schema is still committed — the user gets a clear error and keeps the dirty indicator to re-save, but cross-phase rollback is not available.


Breaking Changes

None for REST API consumers or editor UI users. Two operational changes that may surface for customers with direct database access:

  1. v1 /sale no longer deletes seat_on_event rows. Downstream SQL jobs that assumed the row disappears on sale need to filter by state = 'SOLD'.
  2. The seat_on_event table has two new columns: hold_type VARCHAR(50) and locked_until TIMESTAMP. Additive; existing queries are unaffected.

Database Changes

Flyway migration V103__seat_state_outbox_and_hold_type.sql:

  • Adds hold_type VARCHAR(50) and locked_until TIMESTAMP to seat_on_event
  • Adds hold_type VARCHAR(50) to ga_on_event
  • Creates seat_state_events table with two indexes (one partial, on unprocessed rows)

Analytics views updated in products/db-migrations/manual/analytics-schema.sql:

  • v_bookings_log now exposes hold_type and includes BLOCKED rows
  • v_fleet_totals gains a blocked_now count
  • New view v_seat_state_events exposes the outbox stream joined to organization and tenant metadata

ClickHouse bookingStateEvents table in the statistics service gains oldState, holdType, seatId, groupOfSeatsId, and source columns. The state enum is widened to include BLOCKED. See products/statistics-service/sql/migrations/07-add-outbox-columns/migration.sql for the idempotent ALTER script to run in production.


Configuration

New seatmap.outbox block in booking-service application.yaml:

seatmap:
  outbox:
    enabled: true
    batch-size: 500
    drain-interval-ms: 5000

New seatmap.hold-types.defaults block seeds CART and RESERVED. Customers who want to change the defaults can override via organization or tenant config – the platform defaults are the base of a three-level merge.


Migration Guide

What operators should know: standard rolling upgrade plus the V103 Flyway migration runs automatically on booking-service startup. The ClickHouse ALTER script needs to be applied separately to widen the state enum and add columns; the script is idempotent and safe to re-run.

What integrators should know: the public REST contract is unchanged. The v2 booking StateSelection body accepts an optional holdType field; existing payloads without it continue to behave exactly as before (the default is CART). The editor UI is the only frontend surface affected.


Other improvements

  • SEAT-936 – booking-client now persists sessionId in localStorage, so one visitor across page reloads and new tabs is tracked as one session in stat.seatmap.pro instead of N. Falls back silently to in-memory id when storage is unavailable.
  • SEAT-937 – editor-service no longer leaks sensitive env vars at startup. The startup properties dump now uses pattern-based redaction (matches password, secret, token, private-key, api-key, access-key in both dot-case and UPPER_SNAKE forms). SaaS credentials are being rotated by the Seatmap team as a follow-up to this release; self-hosted / on-prem operators should rotate any credentials that may have been exposed in pre-1.64 logs (see SEAT-937 Phase 2 for the list of affected credential classes).
  • SEAT-938 – editor and booking readiness probes now scope to readinessState only (dropped db and redis from the health group) and set an explicit timeoutSeconds: 5. Transient DB or Redis hiccups no longer evict healthy pods from their Services.
  • SEAT-939 – editor STOMP broker now sends 20s heartbeats, and the editor ingress has proxy-read-timeout / proxy-send-timeout set to 3600s. Eliminates the ~96% transport_error rate on idle WebSocket sessions caused by nginx severing idle connections at 60s.

Additional Documentation


Questions? Contact the Seatmap team or check our documentation at seatmap.pro.