Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions api/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { APP_ENV, DEVTOOL_ACCESS_TOKEN } from './env.ts'
import { respond } from './response.ts'
import type { RequestContext } from '@01edu/types/context'
import { route } from './router.ts'
import { ARR, OBJ, optional, STR } from './validator.ts'
import type { Sql } from '@01edu/types/db'
import { ARR, NUM, OBJ, optional, STR } from './validator.ts'
import type { Metric, Sql } from '@01edu/types/db'

/**
* Authorizes access to developer routes.
Expand Down Expand Up @@ -58,3 +58,24 @@ export const createSqlDevRoute = (sql?: Sql) => {
description: 'Execute an SQL query',
})
}

/**
* Creates a route handler that exposes collected query metrics.
*
* @returns A route handler configuration.
*/
export const createQueryMetricsDevRoute = (metrics: Metric[]) =>
route({
authorize: authorizeDevAccess,
fn: () => metrics,
output: ARR(
OBJ({
query: STR('The SQL query text'),
count: NUM('How many times the query has run'),
duration: NUM('Total time spent running the query in milliseconds'),
explain: STR('The SQL explain text'),
}),
'Collected query metrics',
),
description: 'List collected SQL query metrics',
})
12 changes: 12 additions & 0 deletions api/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export const ENV: EnvGetter = (key, fallback) => {
throw Error(`${key}: field required in the env`)
}

const truthy = (value: string): boolean => {
const v = value.toLowerCase()
return v === 'true' || v === '1' || v === 'yes'
}

/**
* The possible application environments.
*/
Expand Down Expand Up @@ -100,6 +105,13 @@ export const DEVTOOL_URL: string = ENV('DEVTOOL_URL', '')
*/
export const DEVTOOL_ACCESS_TOKEN: string = ENV('DEVTOOL_ACCESS_TOKEN', '')

/**
* Disable query debug instrumentation when set in the environment.
*/
export const DISABLE_QUERY_METRICS: boolean = truthy(
ENV('DISABLE_QUERY_METRICS', ''),
)

const forAppEnv =
(env: AppEnvironments) => (key: string, fallback?: string): string => {
const value = Deno.env.get(key)
Expand Down
24 changes: 11 additions & 13 deletions api/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ import type {
} from '@01edu/types/router'
import type { Log } from './log.ts'
import { respond, ResponseError } from './response.ts'
import type { Sql } from '@01edu/types/db'
import { createSqlDevRoute } from './dev.ts'
import type { Metric, Sql } from '@01edu/types/db'
import { createQueryMetricsDevRoute, createSqlDevRoute } from './dev.ts'
import { createDocRoute } from './doc.ts'
import { createHealthRoute } from './health.ts'

Expand All @@ -34,6 +34,7 @@ export type RouterOptions = {
log: Log
sql?: Sql
sensitiveKeys?: string[]
metrics?: Metric[]
}

/**
Expand Down Expand Up @@ -99,6 +100,7 @@ const sensitiveData = (
* import { makeRouter, route } from '@01edu/router';
* import { logger } from '@01edu/log';
* import { STR } from '@01edu/validator';
* import { metrics } from '@01edu/db';
*
* const log = await logger({});
* const routes = {
Expand All @@ -109,28 +111,24 @@ const sensitiveData = (
* }),
* };
*
* const router = makeRouter(routes, { log });
* const router = makeRouter(routes, { log, metrics });
* ```
*/
const pass = ['password', 'confPassword', 'currentPassword', 'newPassword']
export const makeRouter = <T extends GenericRoutes>(
defs: T,
{
log,
sql,
sensitiveKeys = [
'password',
'confPassword',
'currentPassword',
'newPassword',
],
}: RouterOptions,
{ log, sql, metrics, sensitiveKeys = pass }: RouterOptions,
): (ctx: RequestContext) => Awaitable<Response> => {
const routeMaps: Record<string, Route> = Object.create(null)

if (!defs['POST/api/execute-sql']) {
defs['POST/api/execute-sql'] = createSqlDevRoute(sql)
}

if (!defs['GET/api/metrics-sql'] && metrics) {
defs['GET/api/metrics-sql'] = createQueryMetricsDevRoute(metrics)
}

if (!defs['GET/api/doc']) {
defs['GET/api/doc'] = createDocRoute(defs)
}
Expand Down
165 changes: 81 additions & 84 deletions db/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,11 @@
*/

import { assertEquals } from '@std/assert/equals'
import {
type BindParameters,
type BindValue,
Database,
type RestBindParameters,
} from '@db/sqlite'
import { type BindParameters, type BindValue, Database } from '@db/sqlite'
import type { Expand, MatchKeys, UnionToIntersection } from '@01edu/types'
import type { Sql } from '@01edu/types/db'
import type { Metric, Sql } from '@01edu/types/db'
import { respond } from '@01edu/api/response'
import { APP_ENV, ENV } from '@01edu/api/env'
import { APP_ENV, DISABLE_QUERY_METRICS, ENV } from '@01edu/api/env'

const dbPath = ENV('DATABASE_PATH', ':memory:')
/**
Expand Down Expand Up @@ -99,6 +94,16 @@ type SelectReturnType<T extends Record<string, ColDef>, K extends keyof T> = {
[Column in K]: DBTypes[T[Column]['type']]
}

type SqlResult<
T extends { [k in string]: unknown } | undefined,
P extends BindValue | BindParameters | undefined,
> = {
get: (params?: P) => T | undefined
all: (params?: P) => T[]
value: (params?: P) => T[keyof T][] | undefined
run: (params?: P) => number
}

type FlattenProperties<T extends TableProperties> = Expand<
UnionToIntersection<
{
Expand Down Expand Up @@ -238,10 +243,7 @@ export type TableAPI<N extends string, P extends TableProperties> = {
sql: <
K extends keyof FlattenProperties<P>,
T extends BindParameters | BindValue | undefined,
>(sqlArr: TemplateStringsArray, ...vars: unknown[]) => {
get: (params?: T, ...args: RestBindParameters) => Row<P, K> | undefined
all: (params?: T, ...args: RestBindParameters) => Row<P, K>[]
}
>(sqlArr: TemplateStringsArray, ...vars: unknown[]) => SqlResult<Row<P, K>, T>
}

/**
Expand Down Expand Up @@ -316,94 +318,71 @@ export const createTable = <N extends string, P extends TableProperties>(
'Database expected schema and current schema missmatch, maybe you need a migration ?',
)

const insertStmt = db.prepare(`
INSERT INTO ${name} (${keys.join(', ')})
VALUES (${keys.map((k) => `:${k}`).join(', ')})
`)

const insert = (entries: InferInsertType<P>) => {
insertStmt.run(entries)
return db.lastInsertRowId
}

// Add dynamic update functionality
const primaryKey = Object.keys(properties)
.find((k: keyof P) => properties[k].primary)

const insertStmt = db.prepare(`
INSERT INTO ${name} (${keys.join(', ')})
VALUES (${keys.map((k) => `:${k}`).join(', ')})
`)
const updateStmt = db.prepare(`
UPDATE ${name} SET
${keys.map((k) => `${k} = COALESCE(:${k}, ${k})`).join(', ')}
WHERE ${primaryKey} = :${primaryKey}
`)

const update = (
entries: Expand<
& { [K in PrimaryKeys<P>]: DBTypes[P[K]['type']] }
& Partial<InferInsertType<P>>
>,
) => {
// Make sure the primary key field exists in the entries
if (!entries[primaryKey as keyof typeof entries]) {
throw Error(`Primary key ${primaryKey} must be provided for update`)
}
return updateStmt.run(entries)
}

const existsStmt = db.prepare(`
SELECT EXISTS (SELECT 1 FROM ${name} WHERE ${primaryKey} = ?)
`)

const notFound = { message: `${name} not found` }
const exists = (id: number) => existsStmt.value(id)?.[0] === 1
const assert = (id: number | undefined) => {
if (id && exists(id)) return
throw new respond.NotFoundError(notFound)
}

const getByIdStmt = db.prepare(
`SELECT * FROM ${name} WHERE ${primaryKey} = ? LIMIT 1`.trim(),
)

const get = (id: number): Row<P, keyof FlatProps> | undefined =>
getByIdStmt.get(id)

const require = (id: number | undefined) => {
const match = id && getByIdStmt.get(id)
if (!match) throw new respond.NotFoundError(notFound)
return match as Row<P, keyof FlatProps>
}

const sql = <
K extends keyof FlatProps,
T extends BindParameters | BindValue | undefined,
>(sqlArr: TemplateStringsArray, ...vars: unknown[]) => {
const query = String.raw(sqlArr, ...vars)
const stmt = db.prepare(query)
return {
get: stmt.get.bind(stmt) as (
params?: T,
...args: RestBindParameters
) => Row<P, K> | undefined,
all: stmt.all.bind(stmt) as (
params?: T,
...args: RestBindParameters
) => Row<P, K>[],
}
}

const notFound = { message: `${name} not found` }
const exists = (id: number) => existsStmt.value(id)?.[0] === 1
return {
name,
insert,
update,
insert: (entries: InferInsertType<P>) => {
insertStmt.run(entries)
return db.lastInsertRowId
},
update: (
entries: Expand<
& { [K in PrimaryKeys<P>]: DBTypes[P[K]['type']] }
& Partial<InferInsertType<P>>
>,
) => {
// Make sure the primary key field exists in the entries
if (!entries[primaryKey as keyof typeof entries]) {
throw Error(`Primary key ${primaryKey} must be provided for update`)
}
return updateStmt.run(entries)
},
exists,
get,
require,
assert,
sql,
get: (id: number): Row<P, keyof FlatProps> | undefined =>
getByIdStmt.get(id),
require: (id: number | undefined) => {
const match = id && getByIdStmt.get(id)
if (!match) throw new respond.NotFoundError(notFound)
return match as Row<P, keyof FlatProps>
},
assert: (id: number | undefined) => {
if (id && exists(id)) return
throw new respond.NotFoundError(notFound)
},
sql: (sqlArr, ...vars) =>
sql<Row<P, keyof FlatProps>, BindParameters | BindValue | undefined>(
sqlArr,
...vars,
),
properties,
}
}

/**
* Query metrics collected while query debugging is enabled.
*/
export const metrics: Metric[] = []

/**
* A template literal tag for executing arbitrary SQL queries.
*
Expand All @@ -421,14 +400,32 @@ export const createTable = <N extends string, P extends TableProperties>(
export const sql: Sql = <
T extends { [k in string]: unknown } | undefined,
P extends BindValue | BindParameters | undefined,
>(sqlArr: TemplateStringsArray, ...vars: unknown[]) => {
const query = String.raw(sqlArr, ...vars)
>(sqlArr: TemplateStringsArray, ...vars: unknown[]): SqlResult<T, P> => {
const query = String.raw(sqlArr, ...vars).trim()
const stmt = db.prepare(query)
const boundStmt = {
get: (params?: P) => stmt.get(params) as T | undefined,
all: (params?: P) => stmt.all(params) as T[],
run: (params?: P) => stmt.run(params),
value: (params?: P) => stmt.value(params) as T[keyof T][] | undefined,
}
if (DISABLE_QUERY_METRICS || !query.startsWith('SELECT ')) return boundStmt
const explain = db.prepare(`EXPLAIN QUERY PLAN ${query}`).get()?.detail || ''
const m = { query, explain, count: 0, duration: 0 }
metrics.push(m)

const withMetrics = <R>(fn: (params?: P) => R) => (params?: P): R => {
const start = performance.now()
const result = fn(params)
m.count++
m.duration += performance.now() - start
return result
}
return {
get: stmt.get.bind(stmt) as (params?: P) => T | undefined,
all: stmt.all.bind(stmt) as (params?: P) => T[],
run: stmt.run.bind(stmt),
value: stmt.value.bind(stmt),
get: withMetrics(boundStmt.get),
all: withMetrics(boundStmt.all),
run: withMetrics(boundStmt.run),
value: withMetrics(boundStmt.value),
}
}

Expand Down
9 changes: 8 additions & 1 deletion types/db.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ export type Sql = <
>(sqlArr: TemplateStringsArray, ...vars: unknown[]) => {
get: (params?: P) => T | undefined
all: (params?: P) => T[]
run: (params?: P) => void
run: (params?: P) => number
value: (params?: P) => T[keyof T][] | undefined
}

export type Metric = {
query: string
count: number
duration: number
explain: string
}
6 changes: 6 additions & 0 deletions types/router.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ type ReservedRoutes<Session = any> = {
* @deprecated
*/
'POST/api/execute-sql'?: Handler<Session, Def | undefined, Def | undefined>

/**
* ⚠️ WARNING: You are overriding the system SQL metrics route.
* @deprecated
*/
'GET/api/metrics-sql'?: Handler<Session, Def | undefined, Def | undefined>
}

// deno-lint-ignore no-explicit-any
Expand Down