Restructuring calculator data in database and improve api surface

This commit is contained in:
Dominic Ferrando
2026-07-08 12:11:25 -04:00
parent 91a14252cf
commit cec0d17312
8 changed files with 199 additions and 87 deletions
+14 -9
View File
@@ -39,7 +39,6 @@ export async function up(db: Kysely<any>): Promise<void> {
.$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<any>): Promise<void> {
)
.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<any>): Promise<void> {
// 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()
+27 -13
View File
@@ -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<string> {
@@ -28,31 +26,46 @@ async function seedStandardsDataset(): Promise<string> {
return result.id;
}
async function seedStandardsConfig(datasetId: string): Promise<void> {
async function seedStandardsConfig(datasetId: string): Promise<string> {
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<void> {
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<void> {
try {
const datasetId = await seedStandardsDataset();
await seedStandardsConfig(datasetId);
const standardsConfigId = await seedStandardsConfig(datasetId);
await seedCalculator(standardsConfigId);
log.info("seed finished");
}
catch (err) {
+2 -1
View File
@@ -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;
+45 -20
View File
@@ -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,30 +118,52 @@ 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 })
.get("/standards/config", async () => {
return await s.Calculator.Standards.Config.get();
})
.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 } }) => {
.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() })
})
.get("/config", async () => {
return await s.Calculator.Standards.Config.get();
.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() })
})
)
+21 -38
View File
@@ -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<StandardsConfig> {
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<void> {
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)
async switch(standardsConfigId: string) {
const result = await db.updateTable("calculators")
.set({ standards_config_id: standardsConfigId })
.where("name", "=", this.name)
.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}"`);
});
if (result[0]?.numUpdatedRows === 0n) throw new Error(`No calculator with name "${this.name}"`);
}
}
+82
View File
@@ -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<StandardsConfig & { id: string, name: string, datasetId: string }> {
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 };
}
}
+2
View File
@@ -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) {
@@ -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;