feat: add application productivity foundations
Quality / quality (ubuntu-latest) (push) Failing after 22s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-23 11:13:03 +05:30
parent 46195462c3
commit 64ab20cc95
42 changed files with 1533 additions and 40 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
"version": "0.8.19",
"version": "0.8.20",
"private": true,
"type": "module",
"main": "./src/index.ts",
+19 -2
View File
@@ -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";
+76
View File
@@ -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]),
};
}
+100
View File
@@ -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;
}
+25
View File
@@ -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();
});