Restructuring calculator data in database and improve api surface
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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() })
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -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}"`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user