Skip to content

Releases: cinchapi/runway

Version 1.14.5

14 Apr 12:03

Choose a tag to compare

  • 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

14 Apr 12:03

Choose a tag to compare

  • 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

04 Apr 15:50

Choose a tag to compare

  • Fixed a bug where Selection objects passed to the Runway.select() method did not track state or results. The results were correctly available on the returned Selections container, but the input Selection objects should have also tracked this data. (GH-90)
  • Fixed a bug that allowed filtered Selection reads 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 an Audience could cause subsequent Runway-wide reads to return results that were still narrowed by that audience's visibility rules. (GH-89)
  • Fixed a bug where multiple Selection objects with the same query parameters but different filters passed to a single Runway.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 null or no-op filter via Selection.withInjectedFilter() into a Selection that already had a client-side filter would silently discard the original filter, causing the resulting Selection to return unfiltered results.

Version 1.14.3

03 Apr 21:43

Choose a tag to compare

  • Fixed a bug where AdHocDataSource records were invisible to Runway.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

03 Apr 16:40

Choose a tag to compare

  • 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.userId where both tenants and seats are Set fields). Only single-hop collection navigation worked correctly; paths with multiple collection hops always failed to match. This caused Scope-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 returned false for LINKS_TO queries that use navigation keys terminating at a Record-valued field (e.g., orgs.seats.member LINKS_TO 12345). These queries now correctly match when the navigated record's id satisfies the LINKS_TO condition.
  • 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

03 Apr 01:46

Choose a tag to compare

  • Fixed a bug where the Selection API (Selection.of and Selection.ofAny) did not support unique-result queries, forcing callers to use the legacy findUnique/findAnyUnique methods instead. Added Selection.ofUnique(Class), Selection.ofAnyUnique(Class), and a chainable .unique() method on InitialBuilder and QueryBuilder that produce a UniqueSelection — returning the single matching record (or null) and throwing DuplicateEntryException when more than one match exists.
  • Fixed a bug where passing duplicate Selection objects to Runway#select(Selection...) caused redundant database queries instead of reusing the result from the first occurrence.

Version 1.14.0

02 Apr 12:16

Choose a tag to compare

  • Static Visibility Scopes: Added Scope and static scope registration to the AccessControl framework as a class-level alternative to instance-based visibility checks. When a Scope is registered for an AccessControl type, it is applied during Audience.select() in place of the per-instance $isDiscoverableBy check:
    • 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, and Runway#select(Selection...) for declaring and executing multiple data retrieval operations together. The select() 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() and Runway#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 to select(), find(), count(), load() — including reads through the Audience framework — return the cached data instead of re-querying the database. This is designed for the middleware/handler pattern: middleware calls reserve() and select() to pre-fetch data, route handlers read through find()/count()/load() or Audience methods and transparently benefit from the cache, and unreserve() clears everything at the end of the request.
  • Added Runway#getKnownRecordTypes() to return all known Record subclasses discovered on the classpath at runtime.
  • Fixed a bug where Pagination.applyFilterAndPage would throw a NullPointerException when invoked with a null filter or null page.
  • Fixed a bug where local Criteria evaluation via ConcourseCompiler did not account for non-readable fields, producing results that diverged from how Concourse would resolve the same Criteria server-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 a Record satisfies a Criteria locally. Navigation keys are fully supported, including traversal through private fields and collections of linked Records.
  • Upgraded the concourse-driver-java dependency to 0.12.4 to fix a bug that caused local Criteria evaluation via ConcourseCompiler to provide inconsistent and unexpected results for records that did not contain a value stored under one or more keys in the input Criteria.

Version 1.13.0

12 Mar 12:54

Choose a tag to compare

  • Configurable CollectionPreSelectStrategy: Added CollectionPreSelectStrategy, a configurable enum that controls how Runway pre-selects data for Collection<Record> fields (e.g., List<Dock>, Set<Node>). Previously, loading a Record with a collection of N linked Records issued N individual select() calls — one per element — inside convert(). Three strategies are now available:
    • NAVIGATE — uses Concourse's navigate() API to batch-prefetch all destination Record data in a single call with snapshot atomicity. Requires StaticAnalysis class-aware path computation.
    • BULK_SELECT — scans loaded data for Link values and batch-fetches all discovered targets via concourse.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 is NONE.
    • Works across all query pipelines: load(), find(), and bulk load(Class).
    • Self-referential collections (e.g., List<Node> on Node) 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 @Computed Methods: Added Record#computeOnce(String, Supplier), a protected method that provides opt-in, per-instance memoization for expensive @Computed properties. During serialization, a @Computed method can be invoked through multiple independent paths — directly from a @Derived method, via get(key), and through the serialization supplier — each triggering redundant work. Wrapping the method body with computeOnce() 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 @Computed methods that do not use computeOnce() retain their current behavior of recomputing on every access.
  • Runway.Properties Post-Build Configuration: Runway.Properties (accessed via runway.properties()) is the centralized handle for post-build configuration and inspection of a Runway instance. It exposes getters and setters for collectionPreSelectStrategy and onSave listener registration. The direct Runway#onSave methods are deprecated in favor of the Properties equivalents.

Version 1.12.0 (March 7, 2026)

  • Spurious Save Failure Retry: Added a SpuriousSaveFailureStrategy configuration that controls how Runway handles TransactionException during save operations. When set to RETRY, Runway automatically 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 @Unique constraint reads in concurrent transactions) rather than a genuine data conflict. The default strategy is FAIL_FAST, which preserves the existing behavior.
    • Configure via Runway.builder().spuriousSaveFailureStrategy(SpuriousSaveFailureStrategy.RETRY).
    • Stale data detection uses Concourse's review audit to check whether any external writes occurred after the record was last loaded or saved.
  • Type-Specific Save Listeners: The onSave method on Runway.Builder now supports type-specific listeners via a new onSave(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 for instanceof checks. The existing onSave(Consumer<Record>) method is now equivalent to onSave(Record.class, listener) and matches all records.
    • Compositional: Multiple onSave calls 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.
  • Post-Build Save Listeners: The Runway instance now exposes onSave(Class<T>, Consumer<T>) and onSave(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...) and Record#save(boolean preventStaleWrote)overloads that can be configured to reject a save when any Record in the object graph has been externally modified since it was last loaded or saved. When preventStaleWrites is true, every Record encountered during the save — including linked records — is checked for staleness before its data is written. If stale data is detected, a StaleDataException is thrown and the transaction is rolled back, guaranteeing that externally modified data is never silently overwritten. When preventStaleWrites is false (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 to save(false, records), preserving full backward compatibility.
    • New StaleDataException (extends RunwayException) carries the primary key of the stale Record via StaleDataException.id().
  • Record Refresh: Added a Record.refresh() method that reloads a Record's in-memory state from the database. After a refresh, the Record reflects the latest persisted data and is no longer considered stale, allowing subsequent save(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#save with a single record would fire the save listener twice for the same record.

Version 1.12.0

07 Mar 15:49

Choose a tag to compare

  • Spurious Save Failure Retry: Added a SpuriousSaveFailureStrategy configuration that controls how Runway handles TransactionException during save operations. When set to RETRY, Runway automatically 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 @Unique constraint reads in concurrent transactions) rather than a genuine data conflict. The default strategy is FAIL_FAST, which preserves the existing behavior.
    • Configure via Runway.builder().spuriousSaveFailureStrategy(SpuriousSaveFailureStrategy.RETRY).
    • Stale data detection uses Concourse's review audit to check whether any external writes occurred after the record was last loaded or saved.
  • Type-Specific Save Listeners: The onSave method on Runway.Builder now supports type-specific listeners via a new onSave(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 for instanceof checks. The existing onSave(Consumer<Record>) method is now equivalent to onSave(Record.class, listener) and matches all records.
    • Compositional: Multiple onSave calls 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.
  • Post-Build Save Listeners: The Runway instance now exposes onSave(Class<T>, Consumer<T>) and onSave(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...) and Record#save(boolean preventStaleWrote)overloads that can be configured to reject a save when any Record in the object graph has been externally modified since it was last loaded or saved. When preventStaleWrites is true, every Record encountered during the save — including linked records — is checked for staleness before its data is written. If stale data is detected, a StaleDataException is thrown and the transaction is rolled back, guaranteeing that externally modified data is never silently overwritten. When preventStaleWrites is false (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 to save(false, records), preserving full backward compatibility.
    • New StaleDataException (extends RunwayException) carries the primary key of the stale Record via StaleDataException.id().
  • Record Refresh: Added a Record.refresh() method that reloads a Record's in-memory state from the database. After a refresh, the Record reflects the latest persisted data and is no longer considered stale, allowing subsequent save(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#save with a single record would fire the save listener twice for the same record.

Version 1.11.0

14 Feb 14:41

Choose a tag to compare

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.

  • AccessControl Interface: Records can implement the AccessControl interface 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)
  • Audience Interface: Represents the entity performing database operations and extends DatabaseInterface to provide access-controlled CRUD operations. Records implementing Audience can perform operations on behalf of themselves:

    • read(keys, record): Enforces access control and throws RestrictedAccessException if any requested field is denied
    • frame(keys, record): Filters out inaccessible data instead of throwing exceptions, returning only what the audience can access
    • create(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
  • Anonymous Audience: Provides a singleton audience implementation for unauthenticated users, accessible via Audience.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 Audience framework 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 Gateway automatically routes to the appropriate database operation (find/load or findAny/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 Gateway provides 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 as Criteria, Order, or Page are honored consistently and efficiently.

  • Lazy Initialization: The Gateway is lazily initialized when first accessed through the DatabaseInterface.gateway() method, providing efficient resource management.

  • Method Variants: The Gateway provides both retrieve and retrieveAny methods that correspond to the underlying find/load and findAny/loadAny operations 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: load returns null when 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 an IllegalStateException when 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() and map() operations:
    • Dynamic data (highest priority)
    • Intrinsic data
    • Computed data
    • Derived data (lowest priority)
  • Previous Inconsistency: Previously, get() used the order: dynamic → intrinsic → derived → computed, while map() 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-only Record base class for temporary, non-persistent data structures. Subclasses define their schema through fields like regular Records, but attempts to persist or modify an AdHocRecord will throw an UnsupportedOperationException. 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: A DatabaseInterface implementation that serves a single AdHocRecord type from an in-memory data source. Data is supplied via a Supplier that is evaluated on each query, allowing for dynamic or computed data. The AdHocDataSource supports full query capabilities including Criteria filtering, Order sorting, and Page pagination—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's AdHocRecord type are automatically routed to the attached source instead of the underlying database. Returns an AttachmentScope that implements DatabaseInterface and AutoCloseable for 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)...

Read more