Release 1.64.0
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, andSOLDstates are joined by a newBLOCKEDstate 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/lockendpoint when noholdTypeis 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=falsedisables 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_eventstable 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.seatSpacingnull symmetry (was comparingnulltoundefined, caused every schema with Russian-language rows to appear dirty).IUnderlaynumeric fields defaulted asymmetrically between the normalized model and the stored DTO.IVenueShapeDTOandclientViewBoxnow normalized intoloadedStateat load time so the diff is apples-to-apples.shapesJsonuses 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:
- v1
/saleno longer deletesseat_on_eventrows. Downstream SQL jobs that assumed the row disappears on sale need to filter bystate = 'SOLD'. - The
seat_on_eventtable has two new columns:hold_type VARCHAR(50)andlocked_until TIMESTAMP. Additive; existing queries are unaffected.
Database Changes
Flyway migration V103__seat_state_outbox_and_hold_type.sql:
- Adds
hold_type VARCHAR(50)andlocked_until TIMESTAMPtoseat_on_event - Adds
hold_type VARCHAR(50)toga_on_event - Creates
seat_state_eventstable with two indexes (one partial, on unprocessed rows)
Analytics views updated in products/db-migrations/manual/analytics-schema.sql:
v_bookings_lognow exposeshold_typeand includesBLOCKEDrowsv_fleet_totalsgains ablocked_nowcount- New view
v_seat_state_eventsexposes 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
sessionIdinlocalStorage, so one visitor across page reloads and new tabs is tracked as one session instat.seatmap.proinstead 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-keyin both dot-case andUPPER_SNAKEforms). 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
readinessStateonly (droppeddbandredisfrom the health group) and set an explicittimeoutSeconds: 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-timeoutset to 3600s. Eliminates the ~96%transport_errorrate on idle WebSocket sessions caused by nginx severing idle connections at 60s.
Additional Documentation
- Seat States & Hold Types – conceptual guide to the two-layer model and custom hold type configuration
- Section States & Styling – sibling concept for GA section outlines in the renderer
- CHANGELOG.md – full technical changelog with commit references
Questions? Contact the Seatmap team or check our documentation at seatmap.pro.