feat: add application productivity foundations
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/db",
|
||||
"version": "0.8.19",
|
||||
"version": "0.8.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -61,5 +61,22 @@ export {
|
||||
createRepository,
|
||||
} from "./helpers.ts";
|
||||
export type { Repository } from "./helpers.ts";
|
||||
export { addSeedData, removeSeedData, runSeedQuery } from "./seed.ts";
|
||||
export type { AddSeedOptions, SeedDatabase, SeedRow } from "./seed.ts";
|
||||
export {
|
||||
addSeedData,
|
||||
defineSeed,
|
||||
removeSeedData,
|
||||
runSeedQuery,
|
||||
seedIfMissing,
|
||||
seedUsers,
|
||||
upsertSeedData,
|
||||
} from "./seed.ts";
|
||||
export type {
|
||||
AddSeedOptions,
|
||||
SeedDatabase,
|
||||
SeedRow,
|
||||
SeedStep,
|
||||
SeedUserAccount,
|
||||
UpsertSeedOptions,
|
||||
} from "./seed.ts";
|
||||
export { defineLedger } from "./ledger.ts";
|
||||
export type { LedgerOptions } from "./ledger.ts";
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Db } from "./driver.ts";
|
||||
|
||||
const SAFE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const ident = (value: string) => {
|
||||
if (!SAFE.test(value)) throw new TypeError(`Unsafe ledger identifier: ${value}`);
|
||||
return value;
|
||||
};
|
||||
|
||||
export interface LedgerOptions {
|
||||
db: () => Db;
|
||||
table: string;
|
||||
subjectColumn?: string;
|
||||
amountColumn?: string;
|
||||
kindColumn?: string;
|
||||
reasonColumn?: string;
|
||||
referenceColumn?: string;
|
||||
}
|
||||
|
||||
export function defineLedger(options: LedgerOptions) {
|
||||
const table = ident(options.table);
|
||||
const subject = ident(options.subjectColumn ?? "subject_id");
|
||||
const amount = ident(options.amountColumn ?? "amount");
|
||||
const kind = ident(options.kindColumn ?? "kind");
|
||||
const reason = ident(options.reasonColumn ?? "reason");
|
||||
const reference = ident(options.referenceColumn ?? "ref");
|
||||
const positive = (value: number) => {
|
||||
if (!Number.isSafeInteger(value) || value < 1)
|
||||
throw new RangeError("ledger amount must be a positive safe integer");
|
||||
};
|
||||
const write = async (
|
||||
subjectId: string,
|
||||
delta: number,
|
||||
entryKind: string,
|
||||
entryReason: string,
|
||||
ref = "",
|
||||
) => {
|
||||
if (!subjectId.trim()) throw new TypeError("ledger subject cannot be empty");
|
||||
await options
|
||||
.db()
|
||||
.exec(
|
||||
`INSERT INTO ${table} (${subject},${amount},${kind},${reason},${reference}) VALUES (?,?,?,?,?)`,
|
||||
[subjectId, delta, entryKind, entryReason, ref],
|
||||
);
|
||||
};
|
||||
const balance = async (subjectId: string, db = options.db()) => {
|
||||
const row = await db.one<{ balance: number | string | null }>(
|
||||
`SELECT COALESCE(SUM(${amount}),0) AS balance FROM ${table} WHERE ${subject} = ?`,
|
||||
[subjectId],
|
||||
);
|
||||
return Number(row?.balance ?? 0);
|
||||
};
|
||||
return {
|
||||
balance,
|
||||
async credit(subjectId: string, value: number, entryReason: string, ref = "") {
|
||||
positive(value);
|
||||
await write(subjectId, value, "credit", entryReason, ref);
|
||||
},
|
||||
async debit(subjectId: string, value: number, entryReason: string, ref = "") {
|
||||
positive(value);
|
||||
return options.db().tx(async (db) => {
|
||||
if ((await balance(subjectId, db)) < value) return false;
|
||||
await db.exec(
|
||||
`INSERT INTO ${table} (${subject},${amount},${kind},${reason},${reference}) VALUES (?,?,?,?,?)`,
|
||||
[subjectId, -value, "debit", entryReason, ref],
|
||||
);
|
||||
return true;
|
||||
});
|
||||
},
|
||||
refund: (subjectId: string, value: number, entryReason: string, ref = "") => {
|
||||
positive(value);
|
||||
return write(subjectId, value, "refund", entryReason, ref);
|
||||
},
|
||||
entries: (subjectId: string) =>
|
||||
options.db().all(`SELECT * FROM ${table} WHERE ${subject} = ? ORDER BY id DESC`, [subjectId]),
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,11 @@ export interface AddSeedOptions {
|
||||
conflict?: "error" | "ignore" | "replace";
|
||||
}
|
||||
|
||||
export interface UpsertSeedOptions {
|
||||
key: string | readonly string[];
|
||||
update?: readonly string[];
|
||||
}
|
||||
|
||||
function identifier(value: string, label: string): string {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
|
||||
throw new TypeError(`${label} must be a safe SQL identifier`);
|
||||
@@ -81,3 +86,98 @@ export function runSeedQuery(
|
||||
if (!query.trim()) throw new TypeError("seed query cannot be empty");
|
||||
return db.exec(query, [...data]);
|
||||
}
|
||||
|
||||
/** Portable object upsert for SQLite, PostgreSQL and MySQL. */
|
||||
export async function upsertSeedData(
|
||||
source: SeedDatabase,
|
||||
data: SeedRow | readonly SeedRow[],
|
||||
table: string,
|
||||
options: UpsertSeedOptions,
|
||||
): Promise<ExecResult[]> {
|
||||
const db = database(source);
|
||||
const target = identifier(table, "seed table");
|
||||
const keys = (Array.isArray(options.key) ? options.key : [options.key]).map((key) =>
|
||||
identifier(key, "seed key"),
|
||||
);
|
||||
if (!keys.length) throw new TypeError("upsertSeedData requires at least one key");
|
||||
const results: ExecResult[] = [];
|
||||
for (const row of Array.isArray(data) ? data : [data]) {
|
||||
const entries = Object.entries(row);
|
||||
if (!entries.length) throw new TypeError("seed row cannot be empty");
|
||||
const columns = entries.map(([column]) => identifier(column, "seed column"));
|
||||
for (const key of keys)
|
||||
if (!columns.includes(key)) throw new TypeError(`seed row is missing key '${key}'`);
|
||||
const updates = (options.update ?? columns.filter((column) => !keys.includes(column))).map(
|
||||
(column) => identifier(column, "seed update column"),
|
||||
);
|
||||
const prefix = db.driver.dialect === "mysql" ? "INSERT INTO" : "INSERT INTO";
|
||||
const suffix =
|
||||
db.driver.dialect === "mysql"
|
||||
? ` ON DUPLICATE KEY UPDATE ${updates.map((column) => `${column} = VALUES(${column})`).join(", ")}`
|
||||
: ` ON CONFLICT (${keys.join(", ")}) DO ${
|
||||
updates.length
|
||||
? `UPDATE SET ${updates.map((column) => `${column} = excluded.${column}`).join(", ")}`
|
||||
: "NOTHING"
|
||||
}`;
|
||||
results.push(
|
||||
await db.exec(
|
||||
`${prefix} ${target} (${columns.join(", ")}) VALUES (${columns.map(() => "?").join(", ")})${suffix}`,
|
||||
entries.map(([, value]) => value),
|
||||
),
|
||||
);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function seedIfMissing(
|
||||
source: SeedDatabase,
|
||||
table: string,
|
||||
match: SeedRow,
|
||||
data: SeedRow,
|
||||
): Promise<boolean> {
|
||||
const db = database(source);
|
||||
const target = identifier(table, "seed table");
|
||||
const where = Object.entries(match);
|
||||
if (!where.length) throw new TypeError("seedIfMissing requires match fields");
|
||||
const existing = await db.one(
|
||||
`SELECT 1 AS found FROM ${target} WHERE ${where
|
||||
.map(([column]) => `${identifier(column, "seed column")} = ?`)
|
||||
.join(" AND ")} LIMIT 1`,
|
||||
where.map(([, value]) => value),
|
||||
);
|
||||
if (existing) return false;
|
||||
await addSeedData(db, data, target);
|
||||
return true;
|
||||
}
|
||||
|
||||
export type SeedStep = (db: Db) => void | Promise<void>;
|
||||
|
||||
export function defineSeed(...steps: readonly SeedStep[]): SeedStep {
|
||||
return async (db) =>
|
||||
db.tx(async (transaction) => {
|
||||
for (const step of steps) await step(transaction);
|
||||
});
|
||||
}
|
||||
|
||||
export interface SeedUserAccount {
|
||||
identifier: string;
|
||||
password?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export async function seedUsers<T extends SeedUserAccount, U extends { id: string }>(
|
||||
accounts: readonly T[],
|
||||
options: {
|
||||
find(identifier: string): Promise<U | null>;
|
||||
create(account: T): Promise<U>;
|
||||
configure?(user: U, account: T): void | Promise<void>;
|
||||
},
|
||||
): Promise<U[]> {
|
||||
const users: U[] = [];
|
||||
for (const account of accounts) {
|
||||
const user = (await options.find(account.identifier)) ?? (await options.create(account));
|
||||
await options.configure?.(user, account);
|
||||
users.push(user);
|
||||
}
|
||||
return users;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createDb, defineLedger, upsertSeedData } from "../src/index.ts";
|
||||
import { sqlite } from "../src/adapters/sqlite.ts";
|
||||
|
||||
test("portable seed upserts and ledgers remove application boilerplate", async () => {
|
||||
const db = createDb(sqlite(":memory:"));
|
||||
await db.exec("CREATE TABLE plans (code TEXT PRIMARY KEY, credits INTEGER NOT NULL)");
|
||||
await upsertSeedData(db, { code: "free", credits: 10 }, "plans", { key: "code" });
|
||||
await upsertSeedData(db, { code: "free", credits: 20 }, "plans", { key: "code" });
|
||||
expect((await db.one<{ credits: number }>("SELECT credits FROM plans"))?.credits).toBe(20);
|
||||
await db.exec(
|
||||
"CREATE TABLE ledger (id INTEGER PRIMARY KEY, user_id TEXT, delta INTEGER, kind TEXT, reason TEXT, ref TEXT)",
|
||||
);
|
||||
const ledger = defineLedger({
|
||||
db: () => db,
|
||||
table: "ledger",
|
||||
subjectColumn: "user_id",
|
||||
amountColumn: "delta",
|
||||
});
|
||||
await ledger.credit("u1", 10, "grant");
|
||||
expect(await ledger.debit("u1", 4, "use")).toBe(true);
|
||||
expect(await ledger.balance("u1")).toBe(6);
|
||||
expect(await ledger.debit("u1", 7, "too much")).toBe(false);
|
||||
await db.close();
|
||||
});
|
||||
Reference in New Issue
Block a user