Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ class LicenseExecutorSpec extends AnyFlatSpec with Matchers {
row should contain key "max_materialized_views"
row("max_materialized_views") shouldBe "3"
row should contain key "max_clusters"
row("max_clusters") shouldBe "2"
row("max_clusters") shouldBe "0"
row should contain key "max_result_rows"
row("max_result_rows") shouldBe "10000"
row should contain key "max_concurrent_queries"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,45 +88,63 @@ package object licensing {
}
}

/** Compute the number of days between now and a target instant. Positive = future, negative =
* past. Single source of truth for expiry/grace computations.
*/
def daysBetween(now: java.time.Instant, target: java.time.Instant): Long =
java.time.Duration.between(now, target).toDays

case class LicenseKey(
id: String,
licenseType: LicenseType,
features: Set[Feature],
expiresAt: Option[java.time.Instant],
metadata: Map[String, String] = Map.empty
metadata: Map[String, String] = Map.empty,
quota: Option[Quota] = None,
usage: Option[LicenseUsage] = None,
platform: Option[Platform] = None
) {

/** Whether this is a trial license (Pro trial via API key). */
def isTrial: Boolean = metadata.get("trial").contains("true")

/** Days remaining until expiration, or None if no expiry. */
def daysRemaining: Option[Long] = expiresAt.map { exp =>
java.time.Duration.between(java.time.Instant.now(), exp).toDays
}
/** Days remaining until expiration, or None if no expiry. Positive = not yet expired. */
def daysRemaining: Option[Long] = daysRemainingAt(java.time.Instant.now())

/** Testable variant: days remaining relative to a given instant. */
def daysRemainingAt(now: java.time.Instant): Option[Long] = expiresAt.map(daysBetween(now, _))

/** Days since expiration, or None if no expiry. Positive = expired. */
def daysSinceExpiry: Option[Long] = daysSinceExpiryAt(java.time.Instant.now())

/** Testable variant: days since expiry relative to a given instant. */
def daysSinceExpiryAt(now: java.time.Instant): Option[Long] =
expiresAt.map(exp => -daysBetween(now, exp))
}

object LicenseKey {
val Community: LicenseKey = LicenseKey(
id = "community",
licenseType = LicenseType.Community,
features = Set(Feature.MaterializedViews, Feature.JdbcDriver),
expiresAt = None
features = Set(Feature.MaterializedViews, Feature.JdbcDriver, Feature.FlightSql),
expiresAt = None,
quota = Some(Quota.Community)
)
}

case class Quota(
maxMaterializedViews: Option[Int], // None = unlimited
maxQueryResults: Option[Int], // None = unlimited
maxConcurrentQueries: Option[Int],
maxClusters: Option[Int] = Some(2) // None = unlimited
maxClusters: Option[Int] = Some(0) // None = unlimited
)

object Quota {
val Community: Quota = Quota(
maxMaterializedViews = Some(3),
maxQueryResults = Some(10000),
maxConcurrentQueries = Some(5),
maxClusters = Some(2)
maxClusters = Some(0)
)

val Pro: Quota = Quota(
Expand All @@ -144,10 +162,70 @@ package object licensing {
)
}

sealed trait Platform {
def name: String
override def toString: String = name
}

object Platform {
case object Production extends Platform { val name = "PRODUCTION" }
case object Staging extends Platform { val name = "STAGING" }
case object Integration extends Platform { val name = "INTEGRATION" }
case object Development extends Platform { val name = "DEVELOPMENT" }

val values: Seq[Platform] = Seq(Production, Staging, Integration, Development)

def fromString(s: String): Option[Platform] = s.trim.toUpperCase match {
case "PRODUCTION" => Some(Production)
case "STAGING" => Some(Staging)
case "INTEGRATION" => Some(Integration)
case "DEVELOPMENT" => Some(Development)
case _ => None
}
}

case class LicenseUsage(
totalMvsActive: Int = 0,
totalFederatedClusters: Int = 0
) {
require(totalMvsActive >= 0, s"totalMvsActive must be non-negative, got $totalMvsActive")
require(
totalFederatedClusters >= 0,
s"totalFederatedClusters must be non-negative, got $totalFederatedClusters"
)

/** Check usage for a specific feature against a quota. Returns the exceeded limit, if any. */
def checkQuota(feature: Feature, quota: Quota): Option[QuotaExceeded] = feature match {
case Feature.MaterializedViews =>
quota.maxMaterializedViews.collect {
case max if totalMvsActive > max =>
QuotaExceeded("maxMaterializedViews", totalMvsActive, max)
}
case Feature.Federation =>
quota.maxClusters.collect {
case max if totalFederatedClusters > max =>
QuotaExceeded("maxClusters", totalFederatedClusters, max)
}
case _ => None
}
}

object LicenseUsage {
val Empty: LicenseUsage = LicenseUsage()
}

sealed trait GraceStatus
object GraceStatus {
case object NotInGrace extends GraceStatus

/** @param daysExpired must be computed via `LicenseKey.daysSinceExpiryAt(now)` */
case class EarlyGrace(daysExpired: Long) extends GraceStatus

/** @param daysExpired
* must be computed via `LicenseKey.daysSinceExpiryAt(now)`
* @param daysRemaining
* grace period days remaining (gracePeriodDays - daysExpired)
*/
case class MidGrace(daysExpired: Long, daysRemaining: Long) extends GraceStatus
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,100 @@ class LicenseKeySpec extends AnyFlatSpec with Matchers {
)
key.daysRemaining.get should be < 0L
}

"daysRemainingAt" should "be deterministic with a fixed instant" in {
val now = Instant.parse("2026-01-15T00:00:00Z")
val key = LicenseKey(
id = "org-123",
licenseType = LicenseType.Pro,
features = Set(Feature.MaterializedViews),
expiresAt = Some(Instant.parse("2026-01-25T00:00:00Z")),
metadata = Map.empty
)
key.daysRemainingAt(now) shouldBe Some(10L)
}

"daysSinceExpiryAt" should "return positive for expired keys" in {
val now = Instant.parse("2026-01-20T00:00:00Z")
val key = LicenseKey(
id = "org-123",
licenseType = LicenseType.Pro,
features = Set(Feature.MaterializedViews),
expiresAt = Some(Instant.parse("2026-01-15T00:00:00Z")),
metadata = Map.empty
)
key.daysSinceExpiryAt(now) shouldBe Some(5L)
}

it should "return negative for not-yet-expired keys" in {
val now = Instant.parse("2026-01-10T00:00:00Z")
val key = LicenseKey(
id = "org-123",
licenseType = LicenseType.Pro,
features = Set(Feature.MaterializedViews),
expiresAt = Some(Instant.parse("2026-01-15T00:00:00Z")),
metadata = Map.empty
)
key.daysSinceExpiryAt(now) shouldBe Some(-5L)
}

it should "be consistent with daysRemainingAt (sum to zero)" in {
val now = Instant.parse("2026-01-15T00:00:00Z")
val key = LicenseKey(
id = "org-123",
licenseType = LicenseType.Pro,
features = Set(Feature.MaterializedViews),
expiresAt = Some(Instant.parse("2026-01-22T00:00:00Z")),
metadata = Map.empty
)
val remaining = key.daysRemainingAt(now).get
val expired = key.daysSinceExpiryAt(now).get
(remaining + expired) shouldBe 0L
}

"LicenseKey.Community" should "carry Community quota with no usage or platform" in {
LicenseKey.Community.quota shouldBe Some(Quota.Community)
LicenseKey.Community.usage shouldBe None
LicenseKey.Community.platform shouldBe None
}

"LicenseKey with all new fields" should "preserve quota, usage, and platform" in {
val key = LicenseKey(
id = "pro-key",
licenseType = LicenseType.Pro,
features = Set(Feature.MaterializedViews, Feature.Federation),
expiresAt = Some(Instant.now().plus(Duration.ofDays(365))),
metadata = Map("org_name" -> "Acme Corp"),
quota = Some(Quota.Pro),
usage = Some(LicenseUsage(totalMvsActive = 10, totalFederatedClusters = 2)),
platform = Some(Platform.Production)
)
key.quota shouldBe Some(Quota.Pro)
key.usage shouldBe Some(LicenseUsage(totalMvsActive = 10, totalFederatedClusters = 2))
key.platform shouldBe Some(Platform.Production)
}

it should "support equality with identical field values" in {
val now = Instant.now()
val usage = LicenseUsage(totalMvsActive = 5, totalFederatedClusters = 1)
val key1 = LicenseKey(
"k",
LicenseType.Pro,
Set.empty,
Some(now),
quota = Some(Quota.Pro),
usage = Some(usage),
platform = Some(Platform.Staging)
)
val key2 = LicenseKey(
"k",
LicenseType.Pro,
Set.empty,
Some(now),
quota = Some(Quota.Pro),
usage = Some(usage),
platform = Some(Platform.Staging)
)
key1 shouldBe key2
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,53 +21,45 @@ import org.scalatest.matchers.should.Matchers

class LicenseManagerSpec extends AnyFlatSpec with Matchers {

private val manager = new CommunityLicenseManager

"CommunityLicenseManager" should "include MaterializedViews" in {
val manager = new CommunityLicenseManager
manager.hasFeature(Feature.MaterializedViews) shouldBe true
}

it should "include JdbcDriver" in {
val manager = new CommunityLicenseManager
manager.hasFeature(Feature.JdbcDriver) shouldBe true
}

it should "not include FlightSql" in {
val manager = new CommunityLicenseManager
manager.hasFeature(Feature.FlightSql) shouldBe false
it should "include FlightSql" in {
manager.hasFeature(Feature.FlightSql) shouldBe true
}

it should "not include Federation" in {
val manager = new CommunityLicenseManager
manager.hasFeature(Feature.Federation) shouldBe false
}

it should "not include OdbcDriver" in {
val manager = new CommunityLicenseManager
manager.hasFeature(Feature.OdbcDriver) shouldBe false
}

it should "not include UnlimitedResults" in {
val manager = new CommunityLicenseManager
manager.hasFeature(Feature.UnlimitedResults) shouldBe false
}

it should "not include AdvancedAggregations" in {
val manager = new CommunityLicenseManager
manager.hasFeature(Feature.AdvancedAggregations) shouldBe false
}

it should "return Community quotas" in {
val manager = new CommunityLicenseManager
manager.quotas shouldBe Quota.Community
}

it should "always be Community type" in {
val manager = new CommunityLicenseManager
manager.licenseType shouldBe LicenseType.Community
}

it should "reject any key validation" in {
val manager = new CommunityLicenseManager
manager.validate("PRO-test-key") shouldBe a[Left[_, _]]
manager.validate("ENT-test-key") shouldBe a[Left[_, _]]
manager.validate("anything") shouldBe a[Left[_, _]]
Expand All @@ -77,14 +69,21 @@ class LicenseManagerSpec extends AnyFlatSpec with Matchers {
}

it should "return Left(RefreshNotSupported) on refresh" in {
val manager = new CommunityLicenseManager
manager.refresh() shouldBe Left(RefreshNotSupported)
}

it should "return LicenseKey.Community as currentLicenseKey" in {
manager.currentLicenseKey shouldBe LicenseKey.Community
}

it should "not be a trial" in {
manager.isTrial shouldBe false
}

"LicenseManager trait" should "be source-compatible" in {
val manager: LicenseManager = new CommunityLicenseManager
manager.licenseType shouldBe LicenseType.Community
manager.quotas shouldBe Quota.Community
val m: LicenseManager = new CommunityLicenseManager
m.licenseType shouldBe LicenseType.Community
m.quotas shouldBe Quota.Community
}

it should "default refresh to Left(RefreshNotSupported)" in {
Expand All @@ -97,8 +96,19 @@ class LicenseManagerSpec extends AnyFlatSpec with Matchers {
stub.refresh() shouldBe Left(RefreshNotSupported)
}

it should "default currentLicenseKey to LicenseKey.Community" in {
val stub = new LicenseManager {
def validate(key: String): Either[LicenseError, LicenseKey] = Left(InvalidLicense("stub"))
def hasFeature(feature: Feature): Boolean = false
def quotas: Quota = Quota.Community
def licenseType: LicenseType = LicenseType.Community
}
stub.currentLicenseKey shouldBe LicenseKey.Community
stub.isTrial shouldBe false
}

"DefaultLicenseManager" should "be a deprecated alias for CommunityLicenseManager" in {
val manager: DefaultLicenseManager = new CommunityLicenseManager
manager shouldBe a[CommunityLicenseManager]
val m: DefaultLicenseManager = new CommunityLicenseManager
m shouldBe a[CommunityLicenseManager]
}
}
Loading
Loading