0.1.6Updated a month ago
import State from "@infinity-beyond/modules/state.ts";
import { FeatureFlagRest } from "@infinity-beyond/modules/features/feature_flags.rest.ts";

export class FeatureFlag {
  private constructor() {}

  private static cache: Record<string, I_FeatureFlag> = {}

  static readonly REST = new FeatureFlagRest();

  static has(key: string) {
    return this.cache[key] !== undefined;
  }

  static get ADMIN_API() {
    return FeatureFlag.get('ADMIN_API', 'Enables `/admin` API routes for all data types');
  }

  /**
   * Gets (and creates, if necessary) a feature flag value.
   * 
   * @param key The name of the feature flag
   * @param description Optional. A description for what the feature flag does. Userful for the admin flag management screen.
   * @returns boolean
   */
  static get(key: string, description: string | null = null) {
    if(this.cache[key] !== undefined) {
      const entry = this.cache[key];

      if(description && entry.description !== description) {
        entry.description = description;

        State.PostgresClient.query(`INSERT INTO ${this.flag_table} (key, value, description) VALUES ($1, $2, $3) ON CONFLICT(key) DO UPDATE set description=$3`, [entry.key, entry.value, description]);
      }

      return entry.value;
    };

    this.cache[key] = {
      key,
      value: false,
      description
    };

    State.PostgresClient.query<I_FeatureFlag>(`INSERT INTO ${this.flag_table} (key, value, description) VALUES ($1, $2, $3) ON CONFLICT(key) DO NOTHING`, [key, false, description]);
    this.trigger_update();

    return false;
  }

  static entries() {
    return Object.values({...this.cache});
  }

  protected static set(key: string, value: boolean, request: Request) {
    if(!request.state?.user?.has_permission('SET_FEATURE_FLAGS')) throw new Error('[SYSTEM] access denied');

    if(!this.has(key)) return null;

    if(this.cache[key].value !== value) {
      this.cache[key].value = value;
      this.persist(key, value, request);
    }

    return value;
  }
  
  private static async persist(key: string, value: boolean, request: Request) {
    if(!this.has(key)) return null;
    
    const flag = this.cache[key];

    if(!flag) return;

    if(!flag.id) {
      const { rows: [{ id: id }] } = await State.PostgresClient.query<{ id: number }>(`INSERT INTO ${this.flag_table} (key, value, description) VALUES ($1, $2, $3) ON CONFLICT(key) DO NOTHING RETURNING id`, [flag.key, flag.value, flag.description]);
      flag.id = id;
    }
    
    await State.PostgresClient.query<I_FeatureFlagChange>(`INSERT INTO ${this.changes_table} (flag_id, new_value, draco_user_id) VALUES ($1, $2, $3)`, [flag.id, value, request.state.user!.id!]);
    
    const { rows: [ row ] } = await State.PostgresClient.query<{ updated_value: boolean }>(`INSERT INTO ${this.flag_table} (key, value, description) VALUES ($1, $2, $3) ON CONFLICT(key) DO UPDATE SET value=$2 returning $2 as updated_value`, [key, value, null]);
    
    this.cache[key].value = row.updated_value;
  }

  private static get flag_table() {
    return `_feature_flags`;
  }

  private static get changes_table() {
    return `_feature_flag_changes`;
  }

  static async Setup() {
    return await (await this.init()).trigger_update();
  }

  private static async init() {
    const flag_table = this.flag_table;

    await State.PostgresClient.CreateTable<I_FeatureFlag>(this.flag_table, {
      id: "SERIAL PRIMARY KEY",
      key: "varchar(24) NOT NULL",
      description: "varchar(128)",
      value: "boolean NOT NULL default false",
    }, {
      async onCreate() {
        await State.PostgresClient.query(`
          CREATE UNIQUE INDEX feature_flags_key_idx ON ${flag_table} (key);
        `)
      }
    });

    await State.PostgresClient.CreateTable<I_FeatureFlagChange>(this.changes_table, {
      id: "SERIAL PRIMARY KEY",
      flag_id: "int4 NOT NULL",
      new_value: "boolean NOT NULL",
      draco_user_id: "int4 NOT NULL",
      timestamp: "timestamptz NOT NULL DEFAULT NOW()",
    });

    return this;
  }

  private static async trigger_update() {
    const { rows: [...flags]} = await State.PostgresClient.query<I_FeatureFlag>(`SELECT * FROM ${this.flag_table}`);

    for(const flag of flags) {
      this.cache[flag.key] = flag;
    }

    return this;
  }
}

if(!State.IS_BUILDING) await FeatureFlag.Setup();

export interface I_FeatureFlag {
  id?: number
  key: string
  description: string | null
  value: boolean
}

interface I_FeatureFlagChange {
  id?: number
  flag_id: number
  new_value: boolean
  draco_user_id: number
  timestamp: Date
}