diff --git a/apps/api/src/database/migrations/2026-06-03.ts b/apps/api/src/database/migrations/2026-06-03.ts index 9ed29f8..72ce880 100644 --- a/apps/api/src/database/migrations/2026-06-03.ts +++ b/apps/api/src/database/migrations/2026-06-03.ts @@ -39,7 +39,6 @@ export async function up(db: Kysely): Promise { .$call(addDefaultColumns) .addColumn("name", "text", (cb) => cb.notNull().defaultTo("")) .addColumn("params", "jsonb", (cb) => cb.notNull()) - .addColumn("is_selected", "boolean", (cb) => cb.notNull().defaultTo(false)) .addColumn("dataset_id", "bigint", (cb) => cb.notNull()) .addForeignKeyConstraint( "fk_standards_configs_dataset_id", @@ -50,12 +49,18 @@ export async function up(db: Kysely): Promise { ) .execute(); - // INDEX: ONE_SELECTED_STANDARDS_CONFIG - await db.schema.createIndex("idx_one_selected_standards_config") - .on("standards_configs") - .column("is_selected") - .unique() - .where("is_selected", "=", true) + // TABLE: CALCULATORS + await db.schema.createTable("calculators") + .$call(addDefaultColumns) + .addColumn("name", "text", (cb) => cb.notNull().unique()) + .addColumn("standards_config_id", "bigint", (cb) => cb.notNull()) + .addForeignKeyConstraint( + "fk_calculators_standards_config_id", + ["standards_config_id"], + "standards_configs", + ["id"], + (cb) => cb.onDelete("restrict") + ) .execute(); } @@ -66,8 +71,8 @@ export async function down(db: Kysely): Promise { // INDEX: ONE_ACTIVE_PRODUCT_SYNC await db.schema.dropIndex("idx_one_active_product_sync").ifExists().execute(); - // INDEX: ONE_SELECTED_STANDARDS_CONFIG - await db.schema.dropIndex("idx_one_selected_standards_config").ifExists().execute(); + // TABLE: CALCULATORS + await db.schema.dropTable("calculators").ifExists().execute() // TABLE: STANDARDS_CONFIGS await db.schema.dropTable("standards_configs").ifExists().execute() diff --git a/apps/api/src/database/seed.ts b/apps/api/src/database/seed.ts index 264886e..cf0e6d6 100644 --- a/apps/api/src/database/seed.ts +++ b/apps/api/src/database/seed.ts @@ -3,11 +3,9 @@ import { } from "@blade-and-brawn/calculator"; import { Value } from "@sinclair/typebox/value"; import { db } from "./db"; -import { env, log } from "../util"; +import { DEFAULT_NAME, env, log } from "../util"; import standardsConfig from "./seed-data/standards-config.json" with {type: "json"}; -const DEFAULT_NAME = "Default"; - Value.Assert(StandardsConfigSchema, standardsConfig); async function seedStandardsDataset(): Promise { @@ -28,31 +26,46 @@ async function seedStandardsDataset(): Promise { return result.id; } -async function seedStandardsConfig(datasetId: string): Promise { +async function seedStandardsConfig(datasetId: string): Promise { const existing = await db.selectFrom("standards_configs") .select(["id"]) .where("name", "=", DEFAULT_NAME) .executeTakeFirst(); if (existing) { log.info({ id: existing.id }, "default standards config already seeded, skipping"); - return; + return existing.id; } - const hasSelected = await db.selectFrom("standards_configs") - .select(["id"]) - .where("is_selected", "=", true) - .executeTakeFirst(); - const result = await db.insertInto("standards_configs") .values({ name: DEFAULT_NAME, dataset_id: datasetId, params: standardsConfig.params, - is_selected: !hasSelected, }) .returning("id") .executeTakeFirstOrThrow(); - log.info({ id: result.id, selected: !hasSelected }, "seeded default standards config"); + log.info({ id: result.id }, "seeded default standards config"); + return result.id; +} + +async function seedCalculator(standardsConfigId: string): Promise { + const existing = await db.selectFrom("calculators") + .select(["id"]) + .where("name", "=", DEFAULT_NAME) + .executeTakeFirst(); + if (existing) { + log.info({ id: existing.id }, "default calculator already seeded, skipping"); + return; + } + + const result = await db.insertInto("calculators") + .values({ + name: DEFAULT_NAME, + standards_config_id: standardsConfigId + }) + .returning("id") + .executeTakeFirstOrThrow(); + log.info({ id: result.id }, "seeded default calculator"); } (async () => { @@ -65,7 +78,8 @@ async function seedStandardsConfig(datasetId: string): Promise { try { const datasetId = await seedStandardsDataset(); - await seedStandardsConfig(datasetId); + const standardsConfigId = await seedStandardsConfig(datasetId); + await seedCalculator(standardsConfigId); log.info("seed finished"); } catch (err) { diff --git a/apps/api/src/scripts/analyze.ts b/apps/api/src/scripts/analyze.ts index dfeae78..cc997a4 100644 --- a/apps/api/src/scripts/analyze.ts +++ b/apps/api/src/scripts/analyze.ts @@ -4,8 +4,9 @@ import { import { Activity, clamp, Gender, range } from "@blade-and-brawn/domain"; import { levenbergMarquardt as LM } from "ml-levenberg-marquardt"; import { CalculatorService } from "../services/calculator"; +import { DEFAULT_NAME } from "../util"; -const calculator = new CalculatorService(); +const calculator = new CalculatorService(DEFAULT_NAME); const standardsConfig = await calculator.Standards.Config.get(); for (const activity of Object.values(Activity)) { const DATASET_MAX_LEVEL = 5; diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 87e83c7..7d8d07e 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -11,7 +11,7 @@ import { Webflow, } from "@blade-and-brawn/commerce"; import zipcodesUs from "zipcodes-us"; -import { env, log } from "./util"; +import { DEFAULT_NAME, env, log } from "./util"; import serverTiming from "@elysia/server-timing"; import jwt from "@elysia/jwt"; import { CommerceService } from "./services/commerce"; @@ -19,13 +19,16 @@ import cluster from "node:cluster"; import { randomUUIDv7 } from "bun"; import { CalculatorService, CalculatorUnavailableError } from "./services/calculator"; import { StandardsParamsSchema } from "@blade-and-brawn/calculator"; +import { StandardsService } from "./services/standards"; // SERVICES // ----------- -const s = { - Calculator: new CalculatorService(), - Commerce: new CommerceService() -}; +const s = (() => { + const Standards = new StandardsService(); + const Calculator = new CalculatorService(DEFAULT_NAME); + const Commerce = new CommerceService(); + return { Standards, Calculator, Commerce }; +})(); // ELYSIA // ----------- @@ -47,7 +50,7 @@ export const app = new Elysia() .error({ PrintfulError, WebflowError, - CalculatorUnavailableError, + CalculatorUnavailableError }) .onError(({ code, error }) => { @@ -115,31 +118,53 @@ export const app = new Elysia() activityPerformances: t.Array(ActivityPerformanceSchema) }), }) - // Authenticated .resolve(async ({ jwt, cookie: { auth } }) => { const token = auth.value && await jwt.verify(auth.value); if (!token || !token.sessionId) throw status(401, "Unauthorized"); return { sessionId: token.sessionId.toString() }; }) - .put("/config/:id", async ({ params: { id }, body: { datasetId, params: parameters } }) => { - await s.Calculator.Standards.Config.update(id, datasetId, parameters); - }, { - params: t.Object({ id: t.String() }), - body: t.Object({ datasetId: t.String(), params: StandardsParamsSchema }) - }) - .post("/config", async ({ body: { datasetId, params } }) => { - await s.Calculator.Standards.Config.create(datasetId, params); - }, { - body: t.Object({ datasetId: t.String(), params: StandardsParamsSchema }) - }) - .post("/config/:id/switch", async ({ params: { id } }) => { - await s.Calculator.Standards.Config.switch(id); - }, { - params: t.Object({ id: t.String() }) - }) - .get("/config", async () => { + .get("/standards/config", async () => { return await s.Calculator.Standards.Config.get(); }) + .post("/standards/config/switch", async ({ body: { standardsConfigId: id } }) => { + await s.Calculator.Standards.Config.switch(id); + }, { + body: t.Object({ standardsConfigId: t.String() }) + }) + ) + .group("/standards", (app) => app + .resolve(async ({ jwt, cookie: { auth } }) => { + const token = auth.value && await jwt.verify(auth.value); + if (!token || !token.sessionId) throw status(401, "Unauthorized"); + return { sessionId: token.sessionId.toString() }; + }) + .post("/configs", async ({ body: { name, datasetId, params } }) => { + return await s.Standards.Configs.create(name, datasetId, params); + }, { + body: t.Object({ name: t.String(), datasetId: t.String(), params: StandardsParamsSchema }) + }) + .get("/configs", async () => { + return await s.Standards.Configs.list(); + }) + .get("/configs/:id", async ({ params: { id } }) => { + return await s.Standards.Configs.get(id); + }, { + params: t.Object({ id: t.String() }) + }) + .put("/configs/:id", async ({ params: { id }, body: { name, datasetId, params: parameters } }) => { + await s.Standards.Configs.update(id, name, datasetId, parameters); + }, { + params: t.Object({ id: t.String() }), + body: t.Object({ name: t.String(), datasetId: t.String(), params: StandardsParamsSchema }) + }) + .get("/datasets", async () => { + return await s.Standards.Datasets.list(); + }) + .get("/datasets/:id", async ({ params: { id } }) => { + return await s.Standards.Datasets.get(id); + }, { + params: t.Object({ id: t.String() }) + }) ) // COMMERCE diff --git a/apps/api/src/services/calculator.ts b/apps/api/src/services/calculator.ts index 653fee8..7b50460 100644 --- a/apps/api/src/services/calculator.ts +++ b/apps/api/src/services/calculator.ts @@ -1,8 +1,8 @@ -import { LevelCalculator, Standards, type StandardsParams, StandardsParamsSchema, StandardsDataSchema, type StandardsConfig, type LevelCalculatorOutput } from "@blade-and-brawn/calculator"; +import { LevelCalculator, Standards, type StandardsParams, StandardsParamsSchema, StandardsDataSchema, type StandardsData, type StandardsConfig, type LevelCalculatorOutput } from "@blade-and-brawn/calculator"; import type { ActivityPerformance, Player } from "@blade-and-brawn/domain"; +import { log } from "../util"; import { db } from "../database/db"; import { Value } from "@sinclair/typebox/value"; -import { log } from "../util"; export class CalculatorUnavailableError extends Error { status: number = 503 @@ -10,9 +10,10 @@ export class CalculatorUnavailableError extends Error { export class CalculatorService { private calculator: LevelCalculator | null = null; - Standards: StandardsService = new StandardsService(); + Standards: CalculatorStandardsService; - constructor() { + constructor(private name: string) { + this.Standards = new CalculatorStandardsService(this.name); // eagerly load this.refresh().catch((err) => log.error({ err }, "failed to load initial standards config")); } @@ -38,15 +39,15 @@ export class CalculatorService { } } -class StandardsService { - Config: StandardsConfigService; +class CalculatorStandardsService { + Config: CalculatorStandardsConfigService; - constructor() { - this.Config = new StandardsConfigService(); + constructor(private name: string) { + this.Config = new CalculatorStandardsConfigService(this.name); } } -class StandardsConfigService { +class CalculatorStandardsConfigService { private cache = { refreshInterval: 30_000, lastRefreshedAt: 0, @@ -62,39 +63,27 @@ class StandardsConfigService { }, } - async create(datasetId: string, params: StandardsParams) { - await db.insertInto("standards_configs") - .values({ params, dataset_id: datasetId }) - .execute(); - } + constructor(private name: string) { } - /** Retrieves the currently selected configuration */ async get(): Promise { await this.refresh({ onlyIfStale: true }); - if (!this.cache.data.config) throw new CalculatorUnavailableError("Unable to determine the selected standards configuration"); + if (!this.cache.data.config) throw new CalculatorUnavailableError("Unable to determine the calculator's standards configuration"); return this.cache.data.config; } - async update(id: string, datasetId: string, params: StandardsParams) { - const result = await db.updateTable("standards_configs") - .set({ params, dataset_id: datasetId }) - .where("id", "=", id) - .execute(); - if (result[0]?.numUpdatedRows === 0n) throw new Error(`No config with id "${id}"`); - } - async refresh(opt: { onlyIfStale?: boolean } = {}): Promise { if (!this.cache.isIncomplete() && (opt.onlyIfStale && !this.cache.isStale())) return; try { - const result = await db.selectFrom("standards_configs") + const result = await db.selectFrom("calculators") + .innerJoin("standards_configs", "standards_configs.id", "calculators.standards_config_id") .innerJoin("standards_datasets", "standards_datasets.id", "standards_configs.dataset_id") .select([ "standards_configs.dataset_id as datasetId", "standards_configs.params as params", "standards_datasets.data as data", ]) - .where("standards_configs.is_selected", "=", true) + .where("calculators.name", "=", this.name) .limit(1) .executeTakeFirstOrThrow({ errorConstructor: () => new Error("No standards config selected") }); Value.Assert(StandardsParamsSchema, result.params); @@ -109,21 +98,15 @@ class StandardsConfigService { // config; a failed attempt must not make stale data look fresh this.cache.lastRefreshedAt = Date.now(); } catch (err) { - throw new CalculatorUnavailableError("Unable to determine the selected standards configuration", { cause: err }); + throw new CalculatorUnavailableError("Unable to determine the calculator's standards configuration", { cause: err }); } } - async switch(id: string) { - await db.transaction().execute(async (trx) => { - await trx.updateTable("standards_configs") - .set({ is_selected: false }) - .where("is_selected", "=", true) - .execute(); - const result = await trx.updateTable("standards_configs") - .set({ is_selected: true }) - .where("id", "=", id) - .execute(); - if (result[0]?.numUpdatedRows === 0n) throw new Error(`No standards config with id "${id}"`); - }); + async switch(standardsConfigId: string) { + const result = await db.updateTable("calculators") + .set({ standards_config_id: standardsConfigId }) + .where("name", "=", this.name) + .execute(); + if (result[0]?.numUpdatedRows === 0n) throw new Error(`No calculator with name "${this.name}"`); } } diff --git a/apps/api/src/services/standards.ts b/apps/api/src/services/standards.ts new file mode 100644 index 0000000..60fbcfc --- /dev/null +++ b/apps/api/src/services/standards.ts @@ -0,0 +1,82 @@ +import { StandardsDataSchema, StandardsParamsSchema, type StandardsConfig, type StandardsData, type StandardsParams } from "@blade-and-brawn/calculator"; +import { db } from "../database/db"; +import { Value } from "@sinclair/typebox/value"; + +export class StandardsService { + Configs: StandardsConfigService; + Datasets: StandardsDatasetService; + + constructor() { + this.Configs = new StandardsConfigService(); + this.Datasets = new StandardsDatasetService(); + } +} + +class StandardsConfigService { + async create(name: string, datasetId: string, params: StandardsParams): Promise<{ id: string }> { + return await db.insertInto("standards_configs") + .values({ name, params, dataset_id: datasetId }) + .returning("id") + .executeTakeFirstOrThrow(); + } + + async list(): Promise<{ id: string, name: string, datasetId: string }[]> { + return await db.selectFrom("standards_configs") + .select([ + "id", + "name", + "dataset_id as datasetId", + ]) + .orderBy("name") + .execute(); + } + + async get(id: string): Promise { + const result = await db.selectFrom("standards_configs") + .innerJoin("standards_datasets", "standards_datasets.id", "standards_configs.dataset_id") + .select([ + "standards_configs.id as id", + "standards_configs.name as name", + "standards_configs.dataset_id as datasetId", + "standards_configs.params as params", + "standards_datasets.data as data", + ]) + .where("standards_configs.id", "=", id) + .executeTakeFirstOrThrow({ errorConstructor: () => new Error(`No config with id "${id}"`) }); + Value.Assert(StandardsParamsSchema, result.params); + Value.Assert(StandardsDataSchema, result.data); + return { + id: result.id, + name: result.name, + datasetId: result.datasetId, + params: result.params, + data: result.data, + }; + } + + async update(id: string, name: string, datasetId: string, params: StandardsParams) { + const result = await db.updateTable("standards_configs") + .set({ name, params, dataset_id: datasetId }) + .where("id", "=", id) + .execute(); + if (result[0]?.numUpdatedRows === 0n) throw new Error(`No config with id "${id}"`); + } +} + +class StandardsDatasetService { + async list(): Promise<{ id: string, name: string }[]> { + return await db.selectFrom("standards_datasets") + .select(["id", "name"]) + .orderBy("name") + .execute(); + } + + async get(id: string): Promise<{ id: string, name: string, data: StandardsData }> { + const result = await db.selectFrom("standards_datasets") + .select(["id", "name", "data"]) + .where("id", "=", id) + .executeTakeFirstOrThrow({ errorConstructor: () => new Error(`No dataset with id "${id}"`) }); + Value.Assert(StandardsDataSchema, result.data); + return { id: result.id, name: result.name, data: result.data }; + } +} diff --git a/apps/api/src/util.ts b/apps/api/src/util.ts index 9c4fde1..9a99100 100644 --- a/apps/api/src/util.ts +++ b/apps/api/src/util.ts @@ -22,6 +22,8 @@ export const env = { LOG_LEVEL: optionEnv("LOG_LEVEL", "info"), }; +export const DEFAULT_NAME = "Default"; + function requireEnv(key: string): string { const val = Bun.env[key]; if (!val) { diff --git a/apps/portal/src/routes/(app)/calculator/+page.svelte b/apps/portal/src/routes/(app)/calculator/+page.svelte index 2771ea0..2d13946 100644 --- a/apps/portal/src/routes/(app)/calculator/+page.svelte +++ b/apps/portal/src/routes/(app)/calculator/+page.svelte @@ -52,7 +52,7 @@ if (savedInputStringified) { input = JSON.parse(savedInputStringified); } else { - const res = await api.calculator.config.get(); + const res = await api.calculator.standards.config.get(); if (res.error) throw res.error; Value.Assert(StandardsConfigSchema, res.data); input.standardsConfig = res.data;