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