diff --git a/packages/rule-engine/README.md b/packages/rule-engine/README.md new file mode 100644 index 000000000..36f520f18 --- /dev/null +++ b/packages/rule-engine/README.md @@ -0,0 +1,108 @@ +# Rule Engine + +A TypeScript package for evaluating deployments against defined rules to +determine if they are allowed and which release should be chosen. + +## Overview + +The Rule Engine provides a framework for validating deployments based on +configurable rules. It follows a chain-of-responsibility pattern where rules are +applied sequentially to filter candidate releases. + +## Core Components + +- **RuleEngine**: The main class that sequentially applies rules to filter + candidate releases and selects the most appropriate one. +- **DeploymentResourceRule**: Interface that all rules must implement with a + `filter` method. +- **Releases**: Utility class for managing collections of releases. +- **DeploymentDenyRule**: Blocks deployments based on time restrictions using + recurrence rules. + +## How It Works + +1. Rule evaluation process: + + - Starts with all available releases + - Applies each rule sequentially + - Updates the candidate list after each rule + - If any rule disqualifies all candidates, evaluation stops with denial + - After all rules pass, selects the final release + +2. Release selection follows these priorities: + + - Sequential upgrade releases get priority (oldest first) + - Specified desired release if available + - Otherwise, newest release by creation date + +3. Tracking rejection reasons: + - Each rule provides specific reasons for rejecting individual releases + - The engine tracks these reasons per release ID across all rules + - The final result includes a map of rejected release IDs to their rejection reasons + - This approach eliminates the need for a general reason field, providing more detailed feedback + +## Usage + +```typescript +// Create rule instances +const denyRule = new DeploymentDenyRule({ + // Configure time restrictions + recurrence: { + freq: Frequency.WEEKLY, + byday: [DayOfWeek.SA, DayOfWeek.SU], + }, + timezone: "America/New_York", +}); + +// Create the rule engine +const engine = new RuleEngine([denyRule]); + +// Evaluate against releases +const result = engine.evaluate(availableReleases, context); + +// Check result +if (result.allowed) { + // Use result.chosenRelease +} else { + // Examine specific release rejection reasons + if (result.rejectionReasons) { + for (const [releaseId, reason] of result.rejectionReasons.entries()) { + console.log(`Release ${releaseId} was rejected because: ${reason}`); + } + } +} +``` + +## Extending + +Create new rules by implementing the `DeploymentResourceRule` interface: + +```typescript +class MyCustomRule implements DeploymentResourceRule { + filter(context: DeploymentContext, releases: Releases): RuleResult { + // Track rejection reasons + const rejectionReasons = new Map(); + + // Custom logic to filter releases + const filteredReleases = releases.filter(release => { + // Determine if release meets criteria + const meetsCondition = /* your condition logic */; + + // Track rejection reasons for releases that don't meet criteria + if (!meetsCondition) { + rejectionReasons.set(release.id, "Failed custom condition check"); + } + + return meetsCondition; + }); + + return { + allowedReleases: new Releases(filteredReleases), + rejectionReasons // Map of release IDs to rejection reasons + }; + } +} +``` + +Add custom rules to the engine to extend functionality while maintaining the +existing evaluation flow. diff --git a/packages/rule-engine/eslint.config.js b/packages/rule-engine/eslint.config.js new file mode 100644 index 000000000..d09a7dae7 --- /dev/null +++ b/packages/rule-engine/eslint.config.js @@ -0,0 +1,13 @@ +import baseConfig, { requireJsSuffix } from "@ctrlplane/eslint-config/base"; + +/** @type {import('typescript-eslint').Config} */ +export default [ + { + ignores: ["dist/**"], + rules: { + "@typescript-eslint/require-await": "off", + }, + }, + ...requireJsSuffix, + ...baseConfig, +]; diff --git a/packages/rule-engine/package.json b/packages/rule-engine/package.json new file mode 100644 index 000000000..85c40bef9 --- /dev/null +++ b/packages/rule-engine/package.json @@ -0,0 +1,41 @@ +{ + "name": "@ctrlplane/rule-engine", + "private": true, + "version": "0.1.0", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./dist/index.js" + } + }, + "license": "MIT", + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "test": "vitest", + "clean": "rm -rf .turbo node_modules", + "format": "prettier --check . --ignore-path ../../.gitignore", + "lint": "eslint", + "typecheck": "tsc --noEmit --emitDeclarationOnly false" + }, + "dependencies": { + "@ctrlplane/db": "workspace:*", + "@ctrlplane/validators": "workspace:*", + "@date-fns/tz": "^1.2.0", + "date-fns": "^4.1.0", + "rrule": "^2.8.1", + "zod": "catalog:" + }, + "devDependencies": { + "@ctrlplane/eslint-config": "workspace:*", + "@ctrlplane/prettier-config": "workspace:*", + "@ctrlplane/tsconfig": "workspace:*", + "@types/node": "catalog:node22", + "eslint": "catalog:", + "prettier": "catalog:", + "typescript": "catalog:", + "vitest": "^2.1.9" + }, + "prettier": "@ctrlplane/prettier-config" +} diff --git a/packages/rule-engine/src/index.ts b/packages/rule-engine/src/index.ts new file mode 100644 index 000000000..d402f78c6 --- /dev/null +++ b/packages/rule-engine/src/index.ts @@ -0,0 +1,4 @@ +export * from "./types.js"; +export * from "./releases.js"; +export * from "./rule-engine.js"; +export * from "./rules/index.js"; diff --git a/packages/rule-engine/src/releases.ts b/packages/rule-engine/src/releases.ts new file mode 100644 index 000000000..210087015 --- /dev/null +++ b/packages/rule-engine/src/releases.ts @@ -0,0 +1,302 @@ +import type { DeploymentResourceContext, Release } from "./types.js"; + +/** + * A class that encapsulates candidate releases with utility methods for common + * operations. + * + * This class is used throughout the rule engine to provide consistent handling + * of release collections. Rules should operate on CandidateReleases instances + * and return all valid candidates, not just a single one. This ensures that + * downstream rules have the full set of options to apply their own filtering + * logic. + * + * For example, if a rule determines that sequential upgrades are required, it + * should return all releases that are valid sequential candidates, not just the + * oldest one. This allows subsequent rules to further filter the candidates + * based on their criteria. + */ +export class Releases { + /** + * The internal array of release candidates + */ + private releases: Release[]; + + /** + * Creates a new CandidateReleases instance. + * + * @param releases - The array of releases to manage + */ + constructor(releases: Release[]) { + this.releases = [...releases]; + } + + /** + * Static factory method to create an empty CandidateReleases instance. + * + * @returns A new CandidateReleases instance with no releases + */ + static empty(): Releases { + return new Releases([]); + } + + /** + * Static factory method to create a new CandidateReleases instance. + * + * @param releases - The array of releases to manage + * @returns A new CandidateReleases instance + */ + static from(releases: Release | Release[]): Releases { + const releasesToInclude = Array.isArray(releases) ? releases : [releases]; + return new Releases(releasesToInclude); + } + + /** + * Returns all releases in this collection. + * + * @returns The array of all releases + */ + getAll(): Release[] { + return [...this.releases]; + } + + /** + * Returns the oldest release based on creation date. + * + * @returns The oldest release, or undefined if the collection is empty + */ + getOldest(): Release | undefined { + if (this.releases.length === 0) return undefined; + + return this.releases.reduce( + (oldest, current) => + current.createdAt < (oldest?.createdAt ?? current.createdAt) + ? current + : oldest, + this.releases[0], + ); + } + + /** + * Returns the newest release based on creation date. + * + * @returns The newest release, or undefined if the collection is empty + */ + getNewest(): Release | undefined { + if (this.releases.length === 0) return undefined; + + return this.releases.reduce( + (newest, current) => + current.createdAt > (newest?.createdAt ?? current.createdAt) + ? current + : newest, + this.releases[0], + ); + } + + /** + * Returns the release that matches the desired release ID from the context. + * + * @param context - The deployment context containing the desired release ID + * @returns The desired release if found, or undefined if not found or no ID + * specified + */ + getDesired(context: DeploymentResourceContext): Release | undefined { + if (!context.desiredReleaseId) return undefined; + + return this.releases.find( + (release) => release.id === context.desiredReleaseId, + ); + } + + /** + * Returns the effective target release - either the desired release if + * specified, or the newest available release if no desired release is + * specified. + * + * @param context - The deployment context containing the desired release ID + * @returns The effective target release, or undefined if no candidates are + * available + */ + getEffectiveTarget(context: DeploymentResourceContext): Release | undefined { + if (this.releases.length === 0) return undefined; + return this.getDesired(context) ?? this.getNewest(); + } + + /** + * Filters releases based on a metadata key and value. + * + * @param metadataKey - The metadata key to check + * @param metadataValue - The expected value for the metadata key + * @returns A new CandidateReleases instance with filtered releases + */ + filterByMetadata(metadataKey: string, metadataValue: string): Releases { + return this.filter( + (release) => release.version.metadata[metadataKey] === metadataValue, + ); + } + + /** + * Returns a new CandidateReleases instance sorted by creation date in + * ascending order (oldest first). + * + * @returns A new CandidateReleases instance with sorted releases + */ + sortByCreationDateAsc(): Releases { + const sorted = [...this.releases].sort( + (a, b) => a.createdAt.getTime() - b.createdAt.getTime(), + ); + return new Releases(sorted); + } + + /** + * Returns a new CandidateReleases instance sorted by creation date in + * descending order (newest first). + * + * @returns A new CandidateReleases instance with sorted releases + */ + sortByCreationDateDesc(): Releases { + const sorted = [...this.releases].sort( + (a, b) => b.createdAt.getTime() - a.createdAt.getTime(), + ); + return new Releases(sorted); + } + + /** + * Returns a new CandidateReleases instance with releases created before the + * reference release. + * + * @param referenceRelease - The reference release to compare against + * @returns A new CandidateReleases instance with filtered releases + */ + getCreatedBefore(referenceRelease: Release): Releases { + const filtered = this.releases.filter( + (release) => release.createdAt < referenceRelease.createdAt, + ); + return new Releases(filtered); + } + + /** + * Returns a new CandidateReleases instance with releases created after the + * reference release. + * + * @param referenceRelease - The reference release to compare against + * @returns A new CandidateReleases instance with filtered releases + */ + getCreatedAfter(referenceRelease: Release): Releases { + const filtered = this.releases.filter( + (release) => release.createdAt > referenceRelease.createdAt, + ); + return new Releases(filtered); + } + + /** + * Finds a release by ID. + * + * @param id - The release ID to search for + * @returns The matching release or undefined if not found + */ + findById(id: string): Release | undefined { + return this.releases.find((release) => release.id === id); + } + + /** + * Returns the number of releases in this collection. + * + * @returns The number of releases + */ + get length(): number { + return this.releases.length; + } + + /** + * Checks if the collection is empty. + * + * @returns True if there are no releases, false otherwise + */ + isEmpty(): boolean { + return this.releases.length === 0; + } + + /** + * Creates a new CandidateReleases instance with the given releases added. + * + * @param releases - Releases to add to the collection + * @returns A new CandidateReleases instance + */ + add(releases: Release | Release[]): Releases { + const releasesToAdd = Array.isArray(releases) ? releases : [releases]; + return new Releases([...this.releases, ...releasesToAdd]); + } + + /** + * Maps the releases using a mapping function. + * + * @param mapper - Function to transform each release + * @returns A new array with the mapped values + */ + map(mapper: (release: Release) => T): T[] { + return this.releases.map(mapper); + } + + /** + * Iterates over all releases in the collection. + * + * @param callback - Function to call for each release + */ + forEach(callback: (release: Release) => void): void { + this.releases.forEach(callback); + } + + /** + * Filters the releases using a predicate function. + * + * @param predicate - Function that determines whether to include a release + * @returns A new CandidateReleases instance with filtered releases + */ + filter(predicate: (release: Release) => boolean): Releases { + const filtered = this.releases.filter(predicate); + return new Releases(filtered); + } + + /** + * Finds a release that satisfies the provided predicate. + * + * @param predicate - Function to test each release + * @returns The first release that satisfies the predicate, or undefined if + * none is found + */ + find(predicate: (release: Release) => boolean): Release | undefined { + return this.releases.find(predicate); + } + + /** + * Checks if any release in the collection satisfies the predicate. + * + * @param predicate - Function to test each release + * @returns True if at least one release satisfies the predicate, false + * otherwise + */ + some(predicate: (release: Release) => boolean): boolean { + return this.releases.some(predicate); + } + + /** + * Checks if all releases in the collection satisfy the predicate. + * + * @param predicate - Function to test each release + * @returns True if all releases satisfy the predicate, false otherwise + */ + every(predicate: (release: Release) => boolean): boolean { + return this.releases.every(predicate); + } + + /** + * Returns the release at the specified index. + * + * @param index - The index of the release to return + * @returns The release at the specified index, or undefined if the index is out of bounds + */ + at(index: number): Release | undefined { + return this.releases[index]; + } +} diff --git a/packages/rule-engine/src/rule-engine.ts b/packages/rule-engine/src/rule-engine.ts new file mode 100644 index 000000000..bab004748 --- /dev/null +++ b/packages/rule-engine/src/rule-engine.ts @@ -0,0 +1,191 @@ +import type { Releases } from "./releases.js"; +import type { + DeploymentResourceContext, + DeploymentResourceRule, + DeploymentResourceSelectionResult, + Release, +} from "./types.js"; + +/** + * The RuleEngine applies a sequence of deployment rules to filter candidate + * releases and selects the most appropriate release based on configured + * criteria. + * + * The engine works by passing releases through each rule in sequence, where + * each rule can filter out releases that don't meet specific criteria. After + * all rules have been applied, a final selection strategy is used to choose the + * best remaining release. + * + * @example + * ```typescript + * // Import necessary rules + * import { DeploymentDenyRule } from '@ctrlplane/rule-engine'; + * + * // Create rules with appropriate options + * const rules = [ + * new DeploymentDenyRule({ + * ... + * }) + * ]; + * + * // Create the rule engine + * const ruleEngine = new RuleEngine(rules); + * + * // Evaluate a deployment context + * const result = await ruleEngine.evaluate({ + * desiredReleaseId: 'release-123', + * deployment: { id: 'deploy-456', name: 'prod-api' }, + * resource: { id: 'resource-789', name: 'api-service' }, + * availableReleases: [ + * // Array of available releases to choose from + * ] + * }); + * + * // Handle the result + * if (result.allowed) { + * console.log(`Deployment allowed with release: ${result.chosenRelease.id}`); + * } else { + * console.log(`Deployment denied: ${result.reason}`); + * } + * ``` + */ +export class RuleEngine { + /** + * Creates a new RuleEngine with the specified rules. + * + * @param rules - An array of rules that implement the DeploymentResourceRule + * interface. These rules will be applied in sequence during + * evaluation. + */ + constructor( + private rules: Array< + | (() => Promise | DeploymentResourceRule) + | DeploymentResourceRule + >, + ) {} + + /** + * Evaluates a deployment context against all configured rules to determine if + * the deployment is allowed and which release should be used. + * + * The evaluation process: + * 1. Starts with all available releases as candidates + * 2. Applies each rule in sequence, updating the candidate list after each + * rule + * 3. If any rule disqualifies all candidates, evaluation stops with a denial + * result + * 4. After all rules pass, selects the final release using the configured + * selection strategy + * + * Important implementation details for rule authors: + * - Rules should return ALL valid candidate releases, not just one + * - This ensures subsequent rules have a complete set of options to filter + * - For example, if multiple sequential upgrades are required, all should be + * returned, not just the oldest one + * - Otherwise, a subsequent rule might filter out the only returned + * candidate, even when other valid candidates existed + * + * @param releases - The releases to evaluate + * @param context - The deployment context containing all information needed + * for rule evaluation + * @returns A promise resolving to the evaluation result, including allowed + * status and chosen release + */ + async evaluate( + releases: Releases, + context: DeploymentResourceContext, + ): Promise { + // Track rejection reasons for each release across all rules + let rejectionReasons = new Map(); + + // Apply each rule in sequence to filter candidate releases + for (const rule of this.rules) { + const result = await ( + typeof rule === "function" ? await rule() : rule + ).filter(context, releases); + + // If the rule yields no candidates, we must stop. + if (result.allowedReleases.isEmpty()) { + return { + allowed: false, + rejectionReasons: result.rejectionReasons ?? rejectionReasons, + }; + } + + // Merge any new rejection reasons with our tracking map + if (result.rejectionReasons) { + rejectionReasons = new Map([ + ...rejectionReasons, + ...result.rejectionReasons, + ]); + } + + releases = result.allowedReleases; + } + + // Once all rules pass, select the final release + const chosen = this.selectFinalRelease(context, releases); + return chosen == null + ? { + allowed: false, + rejectionReasons, + } + : { + allowed: true, + chosenRelease: chosen, + rejectionReasons, + }; + } + + /** + * Selects the most appropriate release from the candidate list after all + * rules have been applied. + * + * The selection strategy follows these priorities: + * 1. If sequential upgrade releases are present, select the oldest one + * 2. If a desiredReleaseId is specified and it's in the candidate list, that + * release is selected + * 3. Otherwise, the newest release (by createdAt timestamp) is selected + * + * This selection logic provides a balance between respecting explicit release + * requests and defaulting to the latest available release when no specific + * preference is indicated, while ensuring sequential upgrades are applied in + * the correct order. + * + * @param context - The deployment context containing the desired release ID + * if specified + * @param candidates - The list of release candidates that passed all rules + * @returns The selected release, or undefined if no suitable release can be + * chosen + */ + private selectFinalRelease( + context: DeploymentResourceContext, + candidates: Releases, + ): Release | undefined { + if (candidates.isEmpty()) { + return undefined; + } + + // First, check for sequential upgrades - if present, we must select the + // oldest + const sequentialReleases = this.findSequentialUpgradeReleases(candidates); + if (!sequentialReleases.isEmpty()) return sequentialReleases.getOldest(); + + // No sequential releases, use standard selection logic + return candidates.getEffectiveTarget(context); + } + + /** + * Identifies releases that require sequential upgrade application. + * + * Looks for the standard metadata flag that indicates a release requires + * sequential upgrade application. + * + * @param releases - The releases to check + * @returns A Releases collection with only sequential upgrade releases + */ + private findSequentialUpgradeReleases(releases: Releases): Releases { + // Look for the standard metadata key used by SequentialUpgradeRule + return releases.filterByMetadata("requiresSequentialUpgrade", "true"); + } +} diff --git a/packages/rule-engine/src/rules/__tests__/deployment-deny-rule.test.ts b/packages/rule-engine/src/rules/__tests__/deployment-deny-rule.test.ts new file mode 100644 index 000000000..1b94c8c4e --- /dev/null +++ b/packages/rule-engine/src/rules/__tests__/deployment-deny-rule.test.ts @@ -0,0 +1,292 @@ +import { TZDate } from "@date-fns/tz"; +import { Frequency, RRule } from "rrule"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { DeploymentResourceContext, Release } from "../../types.js"; +import { Releases } from "../../releases.js"; +import { DeploymentDenyRule } from "../deployment-deny-rule.js"; + +describe("DeploymentDenyRule", () => { + let releases: Releases; + let context: DeploymentResourceContext; + + beforeEach(() => { + // Create a sample set of releases + const sampleReleases: Release[] = [ + { + id: "rel-1", + createdAt: new Date("2023-01-01T12:00:00Z"), + version: { + id: "ver-1", + tag: "v1.0.0", + config: {}, + metadata: {}, + }, + variables: {}, + }, + { + id: "rel-2", + createdAt: new Date("2023-01-02T12:00:00Z"), + version: { + id: "ver-2", + tag: "v1.1.0", + config: {}, + metadata: {}, + }, + variables: {}, + }, + ]; + + releases = new Releases(sampleReleases); + + // Create a sample context + context = { + desiredReleaseId: null, + deployment: { + id: "deploy-1", + name: "Test Deployment", + }, + environment: { + id: "env-1", + name: "Test Environment", + }, + resource: { + id: "res-1", + name: "Test Resource", + }, + }; + }); + + it("should allow deployments when not in a denied period", () => { + // Create a rule that denies deployments on Mondays + const rule = new DeploymentDenyRule({ + freq: Frequency.WEEKLY, + byweekday: [RRule.MO], // Monday + dtstart: new Date("2023-01-01T00:00:00Z"), + }); + + // Mock getCurrentTime to return a Sunday + vi.spyOn(rule as any, "getCurrentTime").mockReturnValue( + new Date("2023-01-08T12:00:00Z"), // Sunday + ); + + const result = rule.filter(context, releases); + + // Expect all releases to be allowed + expect(result.allowedReleases.length).toBe(2); + expect(result.rejectionReasons).toBeUndefined(); + }); + + it("should deny deployments when in a denied period", () => { + // Create a rule that denies deployments on Mondays + const rule = new DeploymentDenyRule({ + freq: Frequency.WEEKLY, + byweekday: [RRule.MO], // Monday + dtstart: new Date("2023-01-02T00:00:00Z"), // Monday + tzid: "UTC", + }); + + // Mock getCurrentTime to return a Monday + vi.spyOn(rule as any, "getCurrentTime").mockReturnValue( + new TZDate("2023-01-02T12:00:00Z"), // Monday, Jan 2, 2023 + ); + + const result = rule.filter(context, releases); + + // Expect no releases to be allowed + expect(result.allowedReleases.length).toBe(0); + expect(result.rejectionReasons).toBeDefined(); + expect(result.rejectionReasons?.get("rel-1")).toBe( + "Deployment denied due to time-based restrictions", + ); + expect(result.rejectionReasons?.get("rel-2")).toBe( + "Deployment denied due to time-based restrictions", + ); + }); + + it("should respect the custom deny reason", () => { + const customReason = "Maintenance window in progress"; + const rule = new DeploymentDenyRule({ + freq: Frequency.WEEKLY, + byweekday: [RRule.MO], // Monday + dtstart: new Date("2023-01-02T00:00:00Z"), // Monday + denyReason: customReason, + }); + + // Mock getCurrentTime to return a Monday + vi.spyOn(rule as any, "getCurrentTime").mockReturnValue( + new Date("2023-01-02T12:00:00Z"), // Monday, Jan 2, 2023 + ); + + const result = rule.filter(context, releases); + + // Expect the custom reason to be returned + expect(result.rejectionReasons).toBeDefined(); + expect(result.rejectionReasons?.get("rel-1")).toBe(customReason); + expect(result.rejectionReasons?.get("rel-2")).toBe(customReason); + }); + + it("should check for specific time intervals when dtend is specified", () => { + // Create a rule that denies deployments from 9:00 to 17:00 on weekdays + const rule = new DeploymentDenyRule({ + freq: Frequency.WEEKLY, + byweekday: [RRule.MO, RRule.TU, RRule.WE, RRule.TH, RRule.FR], // Weekdays + dtstart: new Date("2023-01-02T09:00:00Z"), // 9:00 AM + dtend: new Date("2023-01-02T17:00:00Z"), // 5:00 PM + tzid: "UTC", + }); + + // Test time within the denied period (Wednesday at 10:00 AM) + vi.spyOn(rule as any, "getCurrentTime").mockReturnValue( + new Date("2023-01-04T10:00:00Z"), + ); + let result = rule.filter(context, releases); + expect(result.allowedReleases.length).toBe(0); + expect(result.rejectionReasons).toBeDefined(); + + // Test time outside the denied period (Wednesday at 8:00 AM) + vi.spyOn(rule as any, "getCurrentTime").mockReturnValue( + new Date("2023-01-04T08:00:00Z"), + ); + result = rule.filter(context, releases); + expect(result.allowedReleases.length).toBe(2); + expect(result.rejectionReasons).toBeUndefined(); + + // Test time outside the denied period (Wednesday at 6:00 PM) + vi.spyOn(rule as any, "getCurrentTime").mockReturnValue( + new Date("2023-01-04T18:00:00Z"), + ); + result = rule.filter(context, releases); + expect(result.allowedReleases.length).toBe(2); + expect(result.rejectionReasons).toBeUndefined(); + }); + + it("should handle timezone conversions correctly", () => { + // Create a rule that denies deployments from 9:00 to 17:00 EST on weekdays + const rule = new DeploymentDenyRule({ + freq: Frequency.WEEKLY, + byweekday: [RRule.MO, RRule.TU, RRule.WE, RRule.TH, RRule.FR], // Weekdays + dtstart: new Date("2023-01-02T09:00:00Z"), // 9:00 AM EST (UTC-5) + dtend: new Date("2023-01-02T17:00:00Z"), // 5:00 PM EST (UTC-5) + tzid: "America/New_York", + }); + + // Test time within the denied period in ET (10:00 AM EST) + vi.spyOn(rule as any, "getCurrentTime").mockReturnValue( + new Date("2023-01-05T15:00:00Z"), // 10:00 AM EST + ); + let result = rule.filter(context, releases); + expect(result.allowedReleases.length).toBe(0); + expect(result.rejectionReasons).toBeDefined(); + + // Test time outside the denied period in ET (8:00 AM EST) + vi.spyOn(rule as any, "getCurrentTime").mockReturnValue( + new Date("2023-01-04T13:00:00Z"), // 8:00 AM EST + ); + result = rule.filter(context, releases); + expect(result.allowedReleases.length).toBe(2); + expect(result.rejectionReasons).toBeUndefined(); + }); + + it("should handle standard time to daylight time changes correctly (EST -> EDT in March)", () => { + const rule = new DeploymentDenyRule({ + freq: Frequency.DAILY, + dtstart: new Date("2023-03-09T09:00:00Z"), // 9:00am EST + dtend: new Date("2023-03-09T17:00:00Z"), // 5:00pm EST + tzid: "America/New_York", + }); + + /** + * These test UTC 21:30 + * during EST, this is 4:30pm, which is during the denied period + * during EDT, this is 5:30pm, which is outside the denied period + * hence, before the DST change, the rule should deny access, + * and should allow access after the DST change + */ + vi.spyOn(rule as any, "getCurrentTime").mockReturnValue( + new Date("2023-03-11T21:30:00Z"), + ); + let result = rule.filter(context, releases); + expect(result.allowedReleases.length).toBe(0); + expect(result.rejectionReasons).toBeDefined(); + + vi.spyOn(rule as any, "getCurrentTime").mockReturnValue( + new Date("2023-03-12T21:30:00Z"), + ); + result = rule.filter(context, releases); + expect(result.allowedReleases.length).toBe(2); + expect(result.rejectionReasons).toBeUndefined(); + + /** + * These test UTC 13:30 + * during EST, this is 8:30am, which is during the denied period + * during EDT, this is 9:30am, which is outside the denied period + * hence, before the DST change, the rule should deny access, + * and should allow access after the DST change + */ + vi.spyOn(rule as any, "getCurrentTime").mockReturnValue( + new Date("2023-03-11T13:30:00Z"), + ); + result = rule.filter(context, releases); + expect(result.allowedReleases.length).toBe(2); + expect(result.rejectionReasons).toBeUndefined(); + + vi.spyOn(rule as any, "getCurrentTime").mockReturnValue( + new Date("2023-03-12T13:30:00Z"), + ); + const result2 = rule.filter(context, releases); + expect(result2.allowedReleases.length).toBe(0); + expect(result2.rejectionReasons).toBeDefined(); + }); + + it("should handle daylight time to standard time changes correctly (EDT -> EST in November)", () => { + const rule = new DeploymentDenyRule({ + freq: Frequency.DAILY, + dtstart: new Date("2023-11-04T09:00:00Z"), // 9:00am EDT + dtend: new Date("2023-11-04T17:00:00Z"), // 5:00pm EDT + tzid: "America/New_York", + }); + + /** + * These test UTC 13:30 + * during EDT, this is 9:30am, which is during the denied period + * during EST, this is 8:30am, which is outside the denied period + * hence, before the DST change, the rule should deny access, + * and should allow access after the DST change + */ + vi.spyOn(rule as any, "getCurrentTime").mockReturnValue( + new Date("2023-11-04T13:30:00Z"), + ); + let result = rule.filter(context, releases); + expect(result.allowedReleases.length).toBe(0); // Should be DENIED + expect(result.rejectionReasons).toBeDefined(); + + vi.spyOn(rule as any, "getCurrentTime").mockReturnValue( + new Date("2023-11-05T13:30:00Z"), + ); + result = rule.filter(context, releases); + expect(result.allowedReleases.length).toBe(2); // Should be ALLOWED + expect(result.rejectionReasons).toBeUndefined(); + + /** + * These test UTC 21:30 + * during EDT, this is 5:30pm, which is outside the denied period + * during EST, this is 4:30pm, which is during the denied period + * hence, before the DST change, the rule should allow access, + * and should deny access after the DST change + */ + vi.spyOn(rule as any, "getCurrentTime").mockReturnValue( + new Date("2023-11-04T21:30:00Z"), + ); + result = rule.filter(context, releases); + expect(result.allowedReleases.length).toBe(2); // Should be ALLOWED + expect(result.rejectionReasons).toBeUndefined(); + + vi.spyOn(rule as any, "getCurrentTime").mockReturnValue( + new Date("2023-11-05T21:30:00Z"), + ); + result = rule.filter(context, releases); + expect(result.allowedReleases.length).toBe(0); // Should be DENIED + expect(result.rejectionReasons).toBeDefined(); + }); +}); diff --git a/packages/rule-engine/src/rules/deployment-deny-rule.ts b/packages/rule-engine/src/rules/deployment-deny-rule.ts new file mode 100644 index 000000000..4a2f487de --- /dev/null +++ b/packages/rule-engine/src/rules/deployment-deny-rule.ts @@ -0,0 +1,168 @@ +import type { Options as RRuleOptions } from "rrule"; +import { tz, TZDate } from "@date-fns/tz"; +import { + addMilliseconds, + differenceInMilliseconds, + isSameDay, + isWithinInterval, +} from "date-fns"; +import { datetime, RRule } from "rrule"; + +import type { + DeploymentResourceContext, + DeploymentResourceRule, + DeploymentResourceRuleResult, +} from "../types.js"; +import { Releases } from "../releases.js"; + +function getDatePartsInTimeZone(date: Date, timeZone: string) { + const formatter = new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + timeZone, + }); + const parts = formatter.formatToParts(date); + const get = (type: string) => + parts.find((p) => p.type === type)?.value ?? "0"; + + return { + year: parseInt(get("year"), 10), + month: parseInt(get("month"), 10), + day: parseInt(get("day"), 10), + hour: parseInt(get("hour"), 10), + minute: parseInt(get("minute"), 10), + second: parseInt(get("second"), 10), + }; +} + +export interface DeploymentDenyRuleOptions extends Partial { + dtend?: Date | null; + + /** + * Custom reason to return when deployment is denied Defaults to "Deployment + * denied due to time-based restrictions" + */ + denyReason?: string; +} + +export class DeploymentDenyRule implements DeploymentResourceRule { + public readonly name = "DeploymentDenyRule"; + private rrule: RRule; + private denyReason: string; + private dtend: Date | null; + private timezone: string; + private dtstart: Date | null; + + constructor({ + denyReason = "Deployment denied due to time-based restrictions", + dtend = null, + dtstart = null, + until = null, + ...options + }: DeploymentDenyRuleOptions) { + this.timezone = options.tzid ?? "UTC"; + this.denyReason = denyReason; + + const dtStartCasted = + dtstart != null ? this.castTimezone(dtstart, this.timezone) : null; + + const untilCasted = + until != null ? this.castTimezone(until, this.timezone) : null; + + this.rrule = new RRule({ + ...options, + tzid: "UTC", + dtstart: dtStartCasted, + until: untilCasted, + }); + this.dtstart = dtstart; + this.dtend = dtend; + } + + // For testing: allow injecting a custom "now" timestamp + protected getCurrentTime() { + return new Date(); + } + + filter( + _: DeploymentResourceContext, + releases: Releases, + ): DeploymentResourceRuleResult { + const now = this.getCurrentTime(); + + // Check if current time matches one of the rrules + const isDenied = this.isDeniedTime(now); + + if (isDenied) { + // Build rejection reasons for each release + const rejectionReasons = new Map( + releases.map((release) => [release.id, this.denyReason]), + ); + return { allowedReleases: Releases.empty(), rejectionReasons }; + } + + // Allow all releases if time is not denied + return { allowedReleases: releases }; + } + + /** + * Checks if the given time is within a denied period + * + * @param time - The time to check + * @returns true if deployments should be denied at this time + */ + private isDeniedTime(now: Date): boolean { + // now is in whatever timezone of the server. We need to convert it to match + // the timezone for the library + const parts = getDatePartsInTimeZone(now, this.timezone); + const nowDt = datetime( + parts.year, + parts.month, + parts.day, + parts.hour, + parts.minute, + parts.second, + ); + + const occurrence = this.rrule.before(nowDt, true); + + // If there's no occurrence on or before the time, it's not in a denied + // period + if (occurrence == null) return false; + + // If dtend is specified, check if time is between occurrence and occurrence + // + duration + if (this.dtend != null && this.dtstart != null) { + const dtstart = this.castTimezone(this.dtstart, this.timezone); + const dtend = this.castTimezone(this.dtend, this.timezone); + + // Calculate duration in local time to handle DST correctly + const durationMs = differenceInMilliseconds(dtend, dtstart); + const occurrenceEnd = addMilliseconds(occurrence, durationMs); + + return isWithinInterval(nowDt, { + start: occurrence, + end: occurrenceEnd, + }); + } + + // If no dtend, check if the occurrence is on the same day using date-fns + return isSameDay(occurrence, now, { in: tz(this.timezone) }); + } + + /** + * Converts a date to the specified timezone + * + * @param date - The date to convert + * @param timezone - The timezone to convert to + * @returns The date adjusted for the timezone + */ + private castTimezone(date: Date, timezone: string): TZDate { + return new TZDate(date, timezone); + } +} diff --git a/packages/rule-engine/src/rules/index.ts b/packages/rule-engine/src/rules/index.ts new file mode 100644 index 000000000..992f6e43e --- /dev/null +++ b/packages/rule-engine/src/rules/index.ts @@ -0,0 +1 @@ +export * from "./deployment-deny-rule.js"; diff --git a/packages/rule-engine/src/types.ts b/packages/rule-engine/src/types.ts new file mode 100644 index 000000000..64fd9146c --- /dev/null +++ b/packages/rule-engine/src/types.ts @@ -0,0 +1,60 @@ +import type { Releases } from "./releases.js"; + +export type Release = { + id: string; + createdAt: Date; + version: { + id: string; + tag: string; + config: Record; + metadata: Record; + }; + variables: Record; +}; + +export type Deployment = { + id: string; + name: string; + resourceSelector?: object; + versionSelector?: object; +}; + +export type Resource = { + id: string; + name: string; +}; + +export type Environment = { + id: string; + name: string; + resourceSelector?: object; +}; + +export type DeploymentResourceContext = { + desiredReleaseId: string | null; + deployment: Deployment; + environment: Environment; + resource: Resource; +}; + +export type DeploymentResourceRuleResult = { + allowedReleases: Releases; + rejectionReasons?: Map; +}; + +export type DeploymentResourceSelectionResult = { + allowed: boolean; + chosenRelease?: Release; + rejectionReasons: Map; +}; + +/** + * A rule to filter/reorder the candidate versions. + */ +export interface DeploymentResourceRule { + name: string; + filter( + context: DeploymentResourceContext, + releases: Releases, + ): DeploymentResourceRuleResult | Promise; +} diff --git a/packages/rule-engine/tsconfig.json b/packages/rule-engine/tsconfig.json new file mode 100644 index 000000000..e02676b57 --- /dev/null +++ b/packages/rule-engine/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@ctrlplane/tsconfig/internal-package.json", + "compilerOptions": { + "outDir": "dist", + "baseUrl": ".", + "incremental": true, + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": ["*.ts", "src"], + "exclude": ["node_modules"] +} diff --git a/packages/rule-engine/vitest.config.ts b/packages/rule-engine/vitest.config.ts new file mode 100644 index 000000000..8a7a111b2 --- /dev/null +++ b/packages/rule-engine/vitest.config.ts @@ -0,0 +1,15 @@ +import { resolve } from "path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/__tests__/**/*.test.ts"], + exclude: ["**/node_modules/**", "**/dist/**"], + }, + resolve: { + alias: { + "@": resolve(__dirname, "./src"), + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b3323400..3fbcb96c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1221,6 +1221,52 @@ importers: specifier: 'catalog:' version: 5.8.2 + packages/rule-engine: + dependencies: + '@ctrlplane/db': + specifier: workspace:* + version: link:../db + '@ctrlplane/validators': + specifier: workspace:* + version: link:../validators + '@date-fns/tz': + specifier: ^1.2.0 + version: 1.2.0 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 + rrule: + specifier: ^2.8.1 + version: 2.8.1 + zod: + specifier: 'catalog:' + version: 3.24.2 + devDependencies: + '@ctrlplane/eslint-config': + specifier: workspace:* + version: link:../../tooling/eslint + '@ctrlplane/prettier-config': + specifier: workspace:* + version: link:../../tooling/prettier + '@ctrlplane/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@types/node': + specifier: catalog:node22 + version: 22.13.10 + eslint: + specifier: 'catalog:' + version: 9.11.1(jiti@2.3.3) + prettier: + specifier: 'catalog:' + version: 3.5.3 + typescript: + specifier: 'catalog:' + version: 5.8.2 + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@22.13.10)(jsdom@25.0.1)(terser@5.36.0) + packages/secrets: dependencies: '@t3-oss/env-core': @@ -2110,6 +2156,9 @@ packages: '@date-fns/tz@1.1.2': resolution: {integrity: sha512-Xmg2cPmOPQieCLAdf62KtFPU9y7wbQDq1OAzrs/bEQFvhtCPXDiks1CHDE/sTXReRfh/MICVkw/vY6OANHUGiA==} + '@date-fns/tz@1.2.0': + resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==} + '@discoveryjs/json-ext@0.5.7': resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} @@ -10116,6 +10165,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rrule@2.8.1: + resolution: {integrity: sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==} + rrweb-cssom@0.7.1: resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} @@ -11491,23 +11543,23 @@ snapshots: '@aws-sdk/types': 3.696.0 '@aws-sdk/util-locate-window': 3.693.0 '@smithy/util-utf8': 2.3.0 - tslib: 2.8.0 + tslib: 2.8.1 '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 '@aws-sdk/types': 3.696.0 - tslib: 2.8.0 + tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': dependencies: - tslib: 2.8.0 + tslib: 2.8.1 '@aws-crypto/util@5.2.0': dependencies: '@aws-sdk/types': 3.696.0 '@smithy/util-utf8': 2.3.0 - tslib: 2.8.0 + tslib: 2.8.1 '@aws-sdk/client-ec2@3.701.0': dependencies: @@ -11696,7 +11748,7 @@ snapshots: '@smithy/util-middleware': 3.0.10 '@smithy/util-retry': 3.0.10 '@smithy/util-utf8': 3.0.0 - tslib: 2.8.0 + tslib: 2.8.1 transitivePeerDependencies: - aws-crt @@ -11741,7 +11793,7 @@ snapshots: '@smithy/util-middleware': 3.0.10 '@smithy/util-retry': 3.0.10 '@smithy/util-utf8': 3.0.0 - tslib: 2.8.0 + tslib: 2.8.1 transitivePeerDependencies: - aws-crt @@ -11784,7 +11836,7 @@ snapshots: '@smithy/util-middleware': 3.0.10 '@smithy/util-retry': 3.0.10 '@smithy/util-utf8': 3.0.0 - tslib: 2.8.0 + tslib: 2.8.1 transitivePeerDependencies: - aws-crt @@ -11829,7 +11881,7 @@ snapshots: '@smithy/util-middleware': 3.0.10 '@smithy/util-retry': 3.0.10 '@smithy/util-utf8': 3.0.0 - tslib: 2.8.0 + tslib: 2.8.1 transitivePeerDependencies: - aws-crt @@ -11890,7 +11942,7 @@ snapshots: '@smithy/types': 3.7.1 '@smithy/util-middleware': 3.0.10 fast-xml-parser: 4.4.1 - tslib: 2.8.0 + tslib: 2.8.1 '@aws-sdk/credential-provider-env@3.696.0': dependencies: @@ -11898,7 +11950,7 @@ snapshots: '@aws-sdk/types': 3.696.0 '@smithy/property-provider': 3.1.10 '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@aws-sdk/credential-provider-http@3.696.0': dependencies: @@ -11911,7 +11963,7 @@ snapshots: '@smithy/smithy-client': 3.4.4 '@smithy/types': 3.7.1 '@smithy/util-stream': 3.3.1 - tslib: 2.8.0 + tslib: 2.8.1 '@aws-sdk/credential-provider-ini@3.696.0(@aws-sdk/client-sso-oidc@3.696.0(@aws-sdk/client-sts@3.696.0))(@aws-sdk/client-sts@3.696.0)': dependencies: @@ -11927,7 +11979,7 @@ snapshots: '@smithy/property-provider': 3.1.10 '@smithy/shared-ini-file-loader': 3.1.11 '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 transitivePeerDependencies: - '@aws-sdk/client-sso-oidc' - aws-crt @@ -11946,7 +11998,7 @@ snapshots: '@smithy/property-provider': 3.1.10 '@smithy/shared-ini-file-loader': 3.1.11 '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 transitivePeerDependencies: - '@aws-sdk/client-sso-oidc' - aws-crt @@ -11964,7 +12016,7 @@ snapshots: '@smithy/property-provider': 3.1.10 '@smithy/shared-ini-file-loader': 3.1.11 '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 transitivePeerDependencies: - '@aws-sdk/client-sso-oidc' - '@aws-sdk/client-sts' @@ -11983,7 +12035,7 @@ snapshots: '@smithy/property-provider': 3.1.10 '@smithy/shared-ini-file-loader': 3.1.11 '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 transitivePeerDependencies: - '@aws-sdk/client-sso-oidc' - '@aws-sdk/client-sts' @@ -11996,7 +12048,7 @@ snapshots: '@smithy/property-provider': 3.1.10 '@smithy/shared-ini-file-loader': 3.1.11 '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@aws-sdk/credential-provider-sso@3.696.0(@aws-sdk/client-sso-oidc@3.696.0(@aws-sdk/client-sts@3.696.0))': dependencies: @@ -12007,7 +12059,7 @@ snapshots: '@smithy/property-provider': 3.1.10 '@smithy/shared-ini-file-loader': 3.1.11 '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 transitivePeerDependencies: - '@aws-sdk/client-sso-oidc' - aws-crt @@ -12021,7 +12073,7 @@ snapshots: '@smithy/property-provider': 3.1.10 '@smithy/shared-ini-file-loader': 3.1.11 '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 transitivePeerDependencies: - '@aws-sdk/client-sso-oidc' - aws-crt @@ -12033,7 +12085,7 @@ snapshots: '@aws-sdk/types': 3.696.0 '@smithy/property-provider': 3.1.10 '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@aws-sdk/credential-provider-web-identity@3.696.0(@aws-sdk/client-sts@3.699.0)': dependencies: @@ -12042,27 +12094,27 @@ snapshots: '@aws-sdk/types': 3.696.0 '@smithy/property-provider': 3.1.10 '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@aws-sdk/middleware-host-header@3.696.0': dependencies: '@aws-sdk/types': 3.696.0 '@smithy/protocol-http': 4.1.7 '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@aws-sdk/middleware-logger@3.696.0': dependencies: '@aws-sdk/types': 3.696.0 '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@aws-sdk/middleware-recursion-detection@3.696.0': dependencies: '@aws-sdk/types': 3.696.0 '@smithy/protocol-http': 4.1.7 '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@aws-sdk/middleware-sdk-ec2@3.696.0': dependencies: @@ -12073,7 +12125,7 @@ snapshots: '@smithy/signature-v4': 4.2.3 '@smithy/smithy-client': 3.4.4 '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@aws-sdk/middleware-user-agent@3.696.0': dependencies: @@ -12083,7 +12135,7 @@ snapshots: '@smithy/core': 2.5.3 '@smithy/protocol-http': 4.1.7 '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@aws-sdk/region-config-resolver@3.696.0': dependencies: @@ -12092,7 +12144,7 @@ snapshots: '@smithy/types': 3.7.1 '@smithy/util-config-provider': 3.0.0 '@smithy/util-middleware': 3.0.10 - tslib: 2.8.0 + tslib: 2.8.1 '@aws-sdk/token-providers@3.696.0(@aws-sdk/client-sso-oidc@3.696.0(@aws-sdk/client-sts@3.696.0))': dependencies: @@ -12101,7 +12153,7 @@ snapshots: '@smithy/property-provider': 3.1.10 '@smithy/shared-ini-file-loader': 3.1.11 '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@aws-sdk/token-providers@3.699.0(@aws-sdk/client-sso-oidc@3.699.0(@aws-sdk/client-sts@3.699.0))': dependencies: @@ -12110,37 +12162,37 @@ snapshots: '@smithy/property-provider': 3.1.10 '@smithy/shared-ini-file-loader': 3.1.11 '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@aws-sdk/types@3.696.0': dependencies: '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@aws-sdk/util-endpoints@3.696.0': dependencies: '@aws-sdk/types': 3.696.0 '@smithy/types': 3.7.1 '@smithy/util-endpoints': 2.1.6 - tslib: 2.8.0 + tslib: 2.8.1 '@aws-sdk/util-format-url@3.696.0': dependencies: '@aws-sdk/types': 3.696.0 '@smithy/querystring-builder': 3.0.10 '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@aws-sdk/util-locate-window@3.693.0': dependencies: - tslib: 2.8.0 + tslib: 2.8.1 '@aws-sdk/util-user-agent-browser@3.696.0': dependencies: '@aws-sdk/types': 3.696.0 '@smithy/types': 3.7.1 bowser: 2.11.0 - tslib: 2.8.0 + tslib: 2.8.1 '@aws-sdk/util-user-agent-node@3.696.0': dependencies: @@ -12148,7 +12200,7 @@ snapshots: '@aws-sdk/types': 3.696.0 '@smithy/node-config-provider': 3.1.11 '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@azure/abort-controller@2.1.2': dependencies: @@ -12472,6 +12524,8 @@ snapshots: '@date-fns/tz@1.1.2': {} + '@date-fns/tz@1.2.0': {} + '@discoveryjs/json-ext@0.5.7': {} '@drizzle-team/brocli@0.10.1': {} @@ -16138,7 +16192,7 @@ snapshots: '@smithy/abort-controller@3.1.8': dependencies: '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/config-resolver@3.0.12': dependencies: @@ -16146,7 +16200,7 @@ snapshots: '@smithy/types': 3.7.1 '@smithy/util-config-provider': 3.0.0 '@smithy/util-middleware': 3.0.10 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/core@2.5.3': dependencies: @@ -16157,7 +16211,7 @@ snapshots: '@smithy/util-middleware': 3.0.10 '@smithy/util-stream': 3.3.1 '@smithy/util-utf8': 3.0.0 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/credential-provider-imds@3.2.7': dependencies: @@ -16165,7 +16219,7 @@ snapshots: '@smithy/property-provider': 3.1.10 '@smithy/types': 3.7.1 '@smithy/url-parser': 3.0.10 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/fetch-http-handler@4.1.1': dependencies: @@ -16173,33 +16227,33 @@ snapshots: '@smithy/querystring-builder': 3.0.10 '@smithy/types': 3.7.1 '@smithy/util-base64': 3.0.0 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/hash-node@3.0.10': dependencies: '@smithy/types': 3.7.1 '@smithy/util-buffer-from': 3.0.0 '@smithy/util-utf8': 3.0.0 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/invalid-dependency@3.0.10': dependencies: '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/is-array-buffer@2.2.0': dependencies: - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/is-array-buffer@3.0.0': dependencies: - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/middleware-content-length@3.0.12': dependencies: '@smithy/protocol-http': 4.1.7 '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/middleware-endpoint@3.2.3': dependencies: @@ -16210,7 +16264,7 @@ snapshots: '@smithy/types': 3.7.1 '@smithy/url-parser': 3.0.10 '@smithy/util-middleware': 3.0.10 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/middleware-retry@3.0.27': dependencies: @@ -16221,25 +16275,25 @@ snapshots: '@smithy/types': 3.7.1 '@smithy/util-middleware': 3.0.10 '@smithy/util-retry': 3.0.10 - tslib: 2.8.0 + tslib: 2.8.1 uuid: 9.0.1 '@smithy/middleware-serde@3.0.10': dependencies: '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/middleware-stack@3.0.10': dependencies: '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/node-config-provider@3.1.11': dependencies: '@smithy/property-provider': 3.1.10 '@smithy/shared-ini-file-loader': 3.1.11 '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/node-http-handler@3.3.1': dependencies: @@ -16247,28 +16301,28 @@ snapshots: '@smithy/protocol-http': 4.1.7 '@smithy/querystring-builder': 3.0.10 '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/property-provider@3.1.10': dependencies: '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/protocol-http@4.1.7': dependencies: '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/querystring-builder@3.0.10': dependencies: '@smithy/types': 3.7.1 '@smithy/util-uri-escape': 3.0.0 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/querystring-parser@3.0.10': dependencies: '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/service-error-classification@3.0.10': dependencies: @@ -16277,7 +16331,7 @@ snapshots: '@smithy/shared-ini-file-loader@3.1.11': dependencies: '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/signature-v4@4.2.3': dependencies: @@ -16288,7 +16342,7 @@ snapshots: '@smithy/util-middleware': 3.0.10 '@smithy/util-uri-escape': 3.0.0 '@smithy/util-utf8': 3.0.0 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/smithy-client@3.4.4': dependencies: @@ -16298,7 +16352,7 @@ snapshots: '@smithy/protocol-http': 4.1.7 '@smithy/types': 3.7.1 '@smithy/util-stream': 3.3.1 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/types@3.7.1': dependencies: @@ -16308,35 +16362,35 @@ snapshots: dependencies: '@smithy/querystring-parser': 3.0.10 '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/util-base64@3.0.0': dependencies: '@smithy/util-buffer-from': 3.0.0 '@smithy/util-utf8': 3.0.0 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/util-body-length-browser@3.0.0': dependencies: - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/util-body-length-node@3.0.0': dependencies: - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/util-buffer-from@2.2.0': dependencies: '@smithy/is-array-buffer': 2.2.0 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/util-buffer-from@3.0.0': dependencies: '@smithy/is-array-buffer': 3.0.0 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/util-config-provider@3.0.0': dependencies: - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/util-defaults-mode-browser@3.0.27': dependencies: @@ -16344,7 +16398,7 @@ snapshots: '@smithy/smithy-client': 3.4.4 '@smithy/types': 3.7.1 bowser: 2.11.0 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/util-defaults-mode-node@3.0.27': dependencies: @@ -16354,28 +16408,28 @@ snapshots: '@smithy/property-provider': 3.1.10 '@smithy/smithy-client': 3.4.4 '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/util-endpoints@2.1.6': dependencies: '@smithy/node-config-provider': 3.1.11 '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/util-hex-encoding@3.0.0': dependencies: - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/util-middleware@3.0.10': dependencies: '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/util-retry@3.0.10': dependencies: '@smithy/service-error-classification': 3.0.10 '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/util-stream@3.3.1': dependencies: @@ -16386,27 +16440,27 @@ snapshots: '@smithy/util-buffer-from': 3.0.0 '@smithy/util-hex-encoding': 3.0.0 '@smithy/util-utf8': 3.0.0 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/util-uri-escape@3.0.0': dependencies: - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/util-utf8@2.3.0': dependencies: '@smithy/util-buffer-from': 2.2.0 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/util-utf8@3.0.0': dependencies: '@smithy/util-buffer-from': 3.0.0 - tslib: 2.8.0 + tslib: 2.8.1 '@smithy/util-waiter@3.1.9': dependencies: '@smithy/abort-controller': 3.1.8 '@smithy/types': 3.7.1 - tslib: 2.8.0 + tslib: 2.8.1 '@socket.io/component-emitter@3.1.2': {} @@ -21678,6 +21732,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.24.0 fsevents: 2.3.3 + rrule@2.8.1: + dependencies: + tslib: 2.8.1 + rrweb-cssom@0.7.1: optional: true @@ -21700,7 +21758,7 @@ snapshots: rxjs@7.8.1: dependencies: - tslib: 2.7.0 + tslib: 2.8.1 rxjs@7.8.2: dependencies: