Releases: cinchapi/runway
Version 1.14.5
- Fixed a bug where an anonymous audience could not discover access-controlled records that were readable or writable by anonymous unless discoverability was also explicitly granted, unlike non-anonymous audiences who could implicitly discover any record they were permitted to read or write
Version 1.13.1
- Fixed a bug where an anonymous audience could not discover access-controlled records that were readable or writable by anonymous unless discoverability was also explicitly granted, unlike non-anonymous audiences who could implicitly discover any record they were permitted to read or write
Version 1.14.4
- Fixed a bug where
Selectionobjects passed to theRunway.select()method did not track state or results. The results were correctly available on the returnedSelectionscontainer, but the inputSelectionobjects should have also tracked this data. (GH-90) - Fixed a bug that allowed filtered
Selectionreads to poison the reservation cache and cause subsequent reads with the same parameters but a different or absent filter to return incorrect results. For example, a read through anAudiencecould cause subsequentRunway-wide reads to return results that were still narrowed by that audience's visibility rules. (GH-89) - Fixed a bug where multiple
Selectionobjects with the same query parameters but different filters passed to a singleRunway.select()call shared results instead of executing independently, causing the second selection to receive the first selection's filtered results. (GH-92) - Fixed a bug where injecting a
nullor no-op filter viaSelection.withInjectedFilter()into aSelectionthat already had a client-side filter would silently discard the original filter, causing the resultingSelectionto return unfiltered results.
Version 1.14.3
- Fixed a bug where
AdHocDataSourcerecords were invisible toRunway.select()when executing multiple selections simultaneously, causing count and data queries to return empty results (GH-86) - Fixed a bug where
Runway#close()could leave dangling instances in the static registry if closing the connection pool threw an exception, which could interfere with subsequent implicit saves (GH-87)
Version 1.14.2
- Fixed a bug where
Record#matches(Criteria)returned incorrect results for navigation keys that traverse two or more consecutive collection-valued fields (e.g.,tenants.seats.user.userIdwhere bothtenantsandseatsareSetfields). Only single-hop collection navigation worked correctly; paths with multiple collection hops always failed to match. This causedScope-based visibility rules that use multi-hop navigation to incorrectly filter out records that should have been visible. - Fixed a bug where
Record#matches(Criteria)always returnedfalseforLINKS_TOqueries that use navigation keys terminating at aRecord-valued field (e.g.,orgs.seats.member LINKS_TO 12345). These queries now correctly match when the navigated record's id satisfies theLINKS_TOcondition. - Behavioral change:
Record#get(String)with multi-hop navigation keys through consecutive collection-valued fields (e.g.,friends.friends.label) now returns a flat collection of leaf values instead of nested collections-of-collections. The previous nesting was erroneous — the flat result is consistent with how Concourse resolves the same navigation key server-side.
Version 1.14.1
- Fixed a bug where the Selection API (
Selection.ofandSelection.ofAny) did not support unique-result queries, forcing callers to use the legacyfindUnique/findAnyUniquemethods instead. AddedSelection.ofUnique(Class),Selection.ofAnyUnique(Class), and a chainable.unique()method onInitialBuilderandQueryBuilderthat produce aUniqueSelection— returning the single matching record (ornull) and throwingDuplicateEntryExceptionwhen more than one match exists. - Fixed a bug where passing duplicate
Selectionobjects toRunway#select(Selection...)caused redundant database queries instead of reusing the result from the first occurrence.
Version 1.14.0
- Static Visibility Scopes: Added
Scopeand static scope registration to theAccessControlframework as a class-level alternative to instance-based visibility checks. When aScopeis registered for anAccessControltype, it is applied duringAudience.select()in place of the per-instance$isDiscoverableBycheck:Scope.of(Criteria)pushes visibility filtering to the database as a query constraint, ensuring only matching records are returned rather than loading all records and filtering post-load. This is significantly more performant when only a small fraction of records for a class are visible to a given audience.Scope.unrestricted()short-circuits to return all records without any filtering.Scope.none()short-circuits to return no records without any database query.Scope.unsupported()signals that scope-based visibility is not applicable; instance-based checks are used instead.AccessControl.registerVisibilityScope(Class, Function<Audience, Scope>)registers a scope provider for a single class.AccessControl.registerVisibilityScopeHierarchy(Class, Function<Audience, Scope>)registers a scope provider for a class and all known subclasses discovered at runtime, without overwriting any explicit per-class registrations already made.- Instance-based permissions remain the default and are recommended for most use cases. Static scopes are best suited when access rules can be expressed as a well-defined
Criteria(to push filtering to the database) or when access is uniformly all-or-nothing across an entire class.
- Selection API: Added
Selection,Selections, andRunway#select(Selection...)for declaring and executing multiple data retrieval operations together. Theselect()API possibly executes multiple reads in as little as a single database round trips, reducing overhead regardless of any other configuration. - Read Reservations: Added
Runway#reserve()andRunway#unreserve()to activate and deactivate a thread-local reserve that works with both the Selection API and direct read methods. When the reserve is active,select()caches its results so that subsequent calls toselect(),find(),count(),load()— including reads through theAudienceframework — return the cached data instead of re-querying the database. This is designed for the middleware/handler pattern: middleware callsreserve()andselect()to pre-fetch data, route handlers read throughfind()/count()/load()orAudiencemethods and transparently benefit from the cache, andunreserve()clears everything at the end of the request. - Added
Runway#getKnownRecordTypes()to return all knownRecordsubclasses discovered on the classpath at runtime. - Fixed a bug where
Pagination.applyFilterAndPagewould throw aNullPointerExceptionwhen invoked with anullfilter ornullpage. - Fixed a bug where local
Criteriaevaluation viaConcourseCompilerdid not account for non-readable fields, producing results that diverged from how Concourse would resolve the sameCriteriaserver-side. Non-readable (e.g., private) fields are stored in the database and indexed like any other field, so server-side resolution always considers them. Local evaluation now includes all fields regardless of visibility, matching server-side behavior. - Added
Record#matches(Criteria)to test whether aRecordsatisfies aCriterialocally. Navigation keys are fully supported, including traversal through private fields and collections of linkedRecords. - Upgraded the
concourse-driver-javadependency to0.12.4to fix a bug that caused localCriteriaevaluation viaConcourseCompilerto provide inconsistent and unexpected results for records that did not contain a value stored under one or more keys in the inputCriteria.
Version 1.13.0
- Configurable
CollectionPreSelectStrategy: AddedCollectionPreSelectStrategy, a configurable enum that controls howRunwaypre-selects data forCollection<Record>fields (e.g.,List<Dock>,Set<Node>). Previously, loading a Record with a collection of N linked Records issued N individualselect()calls — one per element — insideconvert(). Three strategies are now available:NAVIGATE— uses Concourse'snavigate()API to batch-prefetch all destination Record data in a single call with snapshot atomicity. RequiresStaticAnalysisclass-aware path computation.BULK_SELECT— scans loaded data forLinkvalues and batch-fetches all discovered targets viaconcourse.select(Set<Long>), repeating per depth level until all reachable Records are collected. Schema-agnostic — works for untyped loads without class-specific path computation.NONE— the legacy N+1 behavior where each linked Record is fetched individually.- Configure via
Runway.builder().collectionPreSelectStrategy(CollectionPreSelectStrategy.BULK_SELECT). Default isNONE. - Works across all query pipelines:
load(),find(), and bulkload(Class). - Self-referential collections (e.g.,
List<Node>onNode) are handled with cycle detection to prevent infinite path expansion. - Mixed field types (single
Record+Collection<Record>) work correctly — collection pre-select covers the collection while the existing pre-select path mechanism covers single fields.
computeOnce()Memoization for@ComputedMethods: AddedRecord#computeOnce(String, Supplier), a protected method that provides opt-in, per-instance memoization for expensive@Computedproperties. During serialization, a@Computedmethod can be invoked through multiple independent paths — directly from a@Derivedmethod, viaget(key), and through the serialization supplier — each triggering redundant work. Wrapping the method body withcomputeOnce()ensures all invocation paths share a single cached result, eliminating duplicate computations (e.g., database queries) within a serialization cycle.Record#clearComputeOnceCache()invalidates all cached results, allowing fresh recomputation when the underlying data may have changed.- Opt-in only: existing
@Computedmethods that do not usecomputeOnce()retain their current behavior of recomputing on every access.
Runway.PropertiesPost-Build Configuration:Runway.Properties(accessed viarunway.properties()) is the centralized handle for post-build configuration and inspection of aRunwayinstance. It exposes getters and setters forcollectionPreSelectStrategyandonSavelistener registration. The directRunway#onSavemethods are deprecated in favor of thePropertiesequivalents.
Version 1.12.0 (March 7, 2026)
- Spurious Save Failure Retry: Added a
SpuriousSaveFailureStrategyconfiguration that controls howRunwayhandlesTransactionExceptionduring save operations. When set toRETRY,Runwayautomatically retries a failed save if none of the root records have stale data, indicating the failure was caused by a spurious MVCC conflict (e.g., overlapping@Uniqueconstraint reads in concurrent transactions) rather than a genuine data conflict. The default strategy isFAIL_FAST, which preserves the existing behavior.- Configure via
Runway.builder().spuriousSaveFailureStrategy(SpuriousSaveFailureStrategy.RETRY). - Stale data detection uses Concourse's
reviewaudit to check whether any external writes occurred after the record was last loaded or saved.
- Configure via
- Type-Specific Save Listeners: The
onSavemethod onRunway.Buildernow supports type-specific listeners via a newonSave(Class<T>, Consumer<T>)overload. A listener registered for a type only fires for records that are instances of that type (including subclasses), eliminating the need forinstanceofchecks. The existingonSave(Consumer<Record>)method is now equivalent toonSave(Record.class, listener)and matches all records.- Compositional: Multiple
onSavecalls add listeners rather than replacing previous ones. All matching listeners fire in registration order. - Error Isolation: If a listener throws an exception, it is caught and suppressed, and subsequent matching listeners still fire.
- Compositional: Multiple
- Post-Build Save Listeners: The
Runwayinstance now exposesonSave(Class<T>, Consumer<T>)andonSave(Consumer<Record>)methods that allow registering save listeners after the instance has been built. New listeners are chained with any previously registered listeners. The notification infrastructure (queue and worker thread) is lazily initialized on the first post-build registration if no listeners were configured at build time. - Prevent Stale Writes: Added a
Runway#save(boolean preventStaleWrites, Record...)andRecord#save(boolean preventStaleWrote)overloads that can be configured to reject a save when anyRecordin the object graph has been externally modified since it was last loaded or saved. WhenpreventStaleWritesistrue, everyRecordencountered during the save — including linked records — is checked for staleness before its data is written. If stale data is detected, aStaleDataExceptionis thrown and the transaction is rolled back, guaranteeing that externally modified data is never silently overwritten. WhenpreventStaleWritesisfalse(the default), saves behave as before. This check adds latency proportional to the size of the object graph, so it is best suited for environments where concurrent writes are common and data integrity is paramount.- The existing
save(Record...)method delegates tosave(false, records), preserving full backward compatibility. - New
StaleDataException(extendsRunwayException) carries the primary key of the staleRecordviaStaleDataException.id().
- The existing
- Record Refresh: Added a
Record.refresh()method that reloads aRecord's in-memory state from the database. After a refresh, theRecordreflects the latest persisted data and is no longer considered stale, allowing subsequentsave(true, ...)calls to succeed. - Fixed a bug where save notifications were only fired for top-level records and not for linked records that were recursively saved within the same transaction. All records that actually persist changes during a save operation now receive notifications. Also fixed an issue where
Runway#savewith a single record would fire the save listener twice for the same record.
Version 1.12.0
- Spurious Save Failure Retry: Added a
SpuriousSaveFailureStrategyconfiguration that controls howRunwayhandlesTransactionExceptionduring save operations. When set toRETRY,Runwayautomatically retries a failed save if none of the root records have stale data, indicating the failure was caused by a spurious MVCC conflict (e.g., overlapping@Uniqueconstraint reads in concurrent transactions) rather than a genuine data conflict. The default strategy isFAIL_FAST, which preserves the existing behavior.- Configure via
Runway.builder().spuriousSaveFailureStrategy(SpuriousSaveFailureStrategy.RETRY). - Stale data detection uses Concourse's
reviewaudit to check whether any external writes occurred after the record was last loaded or saved.
- Configure via
- Type-Specific Save Listeners: The
onSavemethod onRunway.Buildernow supports type-specific listeners via a newonSave(Class<T>, Consumer<T>)overload. A listener registered for a type only fires for records that are instances of that type (including subclasses), eliminating the need forinstanceofchecks. The existingonSave(Consumer<Record>)method is now equivalent toonSave(Record.class, listener)and matches all records.- Compositional: Multiple
onSavecalls add listeners rather than replacing previous ones. All matching listeners fire in registration order. - Error Isolation: If a listener throws an exception, it is caught and suppressed, and subsequent matching listeners still fire.
- Compositional: Multiple
- Post-Build Save Listeners: The
Runwayinstance now exposesonSave(Class<T>, Consumer<T>)andonSave(Consumer<Record>)methods that allow registering save listeners after the instance has been built. New listeners are chained with any previously registered listeners. The notification infrastructure (queue and worker thread) is lazily initialized on the first post-build registration if no listeners were configured at build time. - Prevent Stale Writes: Added a
Runway#save(boolean preventStaleWrites, Record...)andRecord#save(boolean preventStaleWrote)overloads that can be configured to reject a save when anyRecordin the object graph has been externally modified since it was last loaded or saved. WhenpreventStaleWritesistrue, everyRecordencountered during the save — including linked records — is checked for staleness before its data is written. If stale data is detected, aStaleDataExceptionis thrown and the transaction is rolled back, guaranteeing that externally modified data is never silently overwritten. WhenpreventStaleWritesisfalse(the default), saves behave as before. This check adds latency proportional to the size of the object graph, so it is best suited for environments where concurrent writes are common and data integrity is paramount.- The existing
save(Record...)method delegates tosave(false, records), preserving full backward compatibility. - New
StaleDataException(extendsRunwayException) carries the primary key of the staleRecordviaStaleDataException.id().
- The existing
- Record Refresh: Added a
Record.refresh()method that reloads aRecord's in-memory state from the database. After a refresh, theRecordreflects the latest persisted data and is no longer considered stale, allowing subsequentsave(true, ...)calls to succeed. - Fixed a bug where save notifications were only fired for top-level records and not for linked records that were recursively saved within the same transaction. All records that actually persist changes during a save operation now receive notifications. Also fixed an issue where
Runway#savewith a single record would fire the save listener twice for the same record.
Version 1.11.0
Access Control Framework
Runway now provides a comprehensive access control framework that enables fine-grained, role-based access management for Record operations. This framework allows developers to define granular access rules that are automatically enforced across all database operations, ensuring data security and privacy at the application level.
-
AccessControlInterface: Records can implement theAccessControlinterface to define granular access rules for creation, discovery, reading, writing, and deletion. The interface provides methods to specify field-level permissions based on the requesting audience:- Discovery Rules:
$isDiscoverableBy(Audience)and$isDiscoverableByAnonymous()control whether records can be found or seen at all - Field Access Rules:
$readableBy(Audience)and$writableBy(Audience)define which fields can be accessed for read and write operations - Lifecycle Rules:
$isCreatableBy(Audience)and$isDeletableBy(Audience)control record creation and deletion permissions - Rule Types: Support for allowlists (
Set.of("field1", "field2")), denylists (Set.of("-field1", "-field2")), combined rules, and special rule sets (AccessControl.ALL_KEYS,AccessControl.NO_KEYS)
- Discovery Rules:
-
AudienceInterface: Represents the entity performing database operations and extendsDatabaseInterfaceto provide access-controlled CRUD operations. Records implementingAudiencecan perform operations on behalf of themselves:read(keys, record): Enforces access control and throwsRestrictedAccessExceptionif any requested field is deniedframe(keys, record): Filters out inaccessible data instead of throwing exceptions, returning only what the audience can accesscreate(record),write(keys, record),delete(record): Access-controlled CRUD operations that respect the target record's access rules- Navigation Support: Automatic access control enforcement for dot-notation field access (e.g.,
job.title,application.candidate.email) where each navigation hop respects the target record's access rules
-
AnonymousAudience: Provides a singleton audience implementation for unauthenticated users, accessible viaAudience.anonymous(). This enables differentiated access rules between authenticated and anonymous users. -
RestrictedAccessException: A runtime exception thrown when an audience attempts unauthorized operations on access-controlled records, providing clear security boundary enforcement.
This access control framework enables developers to build secure, multi-tenant applications with role-based access patterns while maintaining the simplicity and performance characteristics of Runway's existing Record operations.
Auditing
Runway now provides comprehensive auditing capabilities that hook into Concourse's ability to automatically track all changes to Record instances over time. The new audit() method returns a chronological history of modifications, including what changed, when it changed, and, possibly, who made the changes:
- Complete Change History: Each timestamp in the audit trail represents a save operation, with associated revisions showing specific field changes
- Author Attribution: Changes made through the
Audienceframework are automatically attributed to the responsible Audience, providing clear accountability for all modifications - Unattributed Changes: Changes made outside the Audience framework are still tracked but marked as "unknown author", ensuring complete visibility into all record modifications
- Flexible Filtering: Support for both positive and negative key filtering (e.g.,
audit("name", "-internal")) allows focusing on specific fields while excluding unwanted data - Change Type Detection: The audit system automatically identifies SET operations (new values), CLEARED operations (removed values), and CHANGED operations (modified values)
- Intrinsic Properties Only: Change tracking is limited to intrinsic properties; computed, derived, and dynamic properties are not included in the audit trail as they are calculated on-demand rather than stored persistently
Interface Default Method Support for Annotations
Runway now recognizes @Derived and @Computed annotations on interface default methods, allowing implementing Records to inherit these property definitions without requiring explicit method overrides.
- Reusable Property Definitions: This enables creating interfaces that define common derived or computed properties across multiple Record types
Pluggable ID Support
Runway now allows Records to provide a custom "id" dynamic property that will be returned instead of the database ID for calls to get("id"), map(), and other data access methods.
- Database ID Preservation: The actual database ID remains unchanged and is always accessible using the
id()method - Fallback Behavior: If no dynamic "id" property is provided, the database ID is still returned for that key
Gateway Database Access Layer
Runway now provides a Gateway class that offers intelligent routing between database operations, simplifying database access by automatically choosing the most appropriate underlying operation based on the parameters provided.
-
Intelligent Operation Routing: The
Gatewayautomatically routes to the appropriate database operation (find/loadorfindAny/loadAny) based on the parameters provided. When any parameters are null, they are ignored in the retrieval process, allowing for flexible and concise database queries. -
Unified Access Interface: The
Gatewayprovides a unified entry point for some database access without requiring manual accounting for optional arguments or conditional logic. This design allows client code to remain concise and expressive while ensuring that optional input such asCriteria,Order, orPageare honored consistently and efficiently. -
Lazy Initialization: The
Gatewayis lazily initialized when first accessed through theDatabaseInterface.gateway()method, providing efficient resource management. -
Method Variants: The
Gatewayprovides bothretrieveandretrieveAnymethods that correspond to the underlyingfind/loadandfindAny/loadAnyoperations respectively, with full support for filtering, sorting, pagination, and realm-based access control.
Load Behavior Change
The load(Class, long) method in DatabaseInterface now returns null instead of throwing an IllegalStateException when attempting to load a Record with a non-existing ID.
- New Behavior:
loadreturnsnullwhen the Record does not exist, allowing callers to handle missing records gracefully without exception handling - Backwards Compatibility: The new
loadNullSafe(Class, long)method preserves the previous fail-fast behavior by throwing anIllegalStateExceptionwhen the Record does not exist
Data Priority Consistency Fix
Fixed a bug where there was inconsistent priorities in the order of data returned from get() vs map() operations.
- Priority Order Standardization: The priority order for data resolution has been standardized across both
get()andmap()operations:- Dynamic data (highest priority)
- Intrinsic data
- Computed data
- Derived data (lowest priority)
- Previous Inconsistency: Previously,
get()used the order: dynamic → intrinsic → derived → computed, whilemap()used: dynamic → intrinsic → computed → derived - Impact: This fix resolves issues that occurred when the same key was used in both computed and derived data, ensuring consistent behavior across all data access methods
Ad-Hoc Records
Runway now provides infrastructure for serving non-persistent, in-memory data through the standard DatabaseInterface API. This enables seamless integration of programmatic data sources with persistent database records, with thread-local scoping for request-aware data attachment.
-
AdHocRecord: A read-onlyRecordbase class for temporary, non-persistent data structures. Subclasses define their schema through fields like regular Records, but attempts to persist or modify anAdHocRecordwill throw anUnsupportedOperationException. This is useful for generating report-like structures, aggregated data views, or other read-only data representations that need to be compatible with the application's data access patterns. -
AdHocDataSource: ADatabaseInterfaceimplementation that serves a singleAdHocRecordtype from an in-memory data source. Data is supplied via aSupplierthat is evaluated on each query, allowing for dynamic or computed data. TheAdHocDataSourcesupports full query capabilities includingCriteriafiltering,Ordersorting, andPagepagination—all resolved in-memory against the supplied collection. -
Runway.attach(AdHocDataSource...): Attaches one or more ad-hoc data sources to the Runway instance for the current thread. When attached, queries for the source'sAdHocRecordtype are automatically routed to the attached source instead of the underlying database. Returns anAttachmentScopethat implementsDatabaseInterfaceandAutoCloseablefor convenient try-with-resources usage:AdHocDataSource<ReportRecord> reports = new AdHocDataSource<>( ReportRecord.class, () -> generateReports()); // Using try-with-resources for automatic cleanup try (AttachmentScope scope = runway.attach(reports)) { // Both handles serve attached data scope.load(ReportRecord.class); // Returns reports runway.load(ReportRecord.class); // Also returns reports runway.load(User.class); // Routes to database } // Sources automatically detached on close
-
**`Runway.detach(AdHocDataSource)...