feat: make queues durable by default and add seed helpers
This commit is contained in:
@@ -297,3 +297,16 @@ const users = await usersRepo.all({
|
||||
Repository SQL identifiers are validated and values remain parameterized. Placeholder generation is dialect-aware: PostgreSQL uses `$1`, `$2`, and SQLite/MySQL use `?`. List limits are bounded.
|
||||
|
||||
`retryTransaction()` retries recognized serialization, deadlock, and database-lock errors by default. Supply `shouldRetry` for application-specific retryable errors; ordinary validation or business errors are not retried automatically.
|
||||
|
||||
## Reusable seed helpers
|
||||
|
||||
```ts
|
||||
import { addSeedData, removeSeedData, runSeedQuery, getDb } from "@wrnexus/db";
|
||||
|
||||
await addSeedData(getDb, [{ code: "free", credits: 100 }], "plans", { conflict: "ignore" });
|
||||
await removeSeedData(getDb(), { code: "legacy" }, "plans");
|
||||
await runSeedQuery(getDb(), "UPDATE plans SET credits = ? WHERE code = ?", [200, "free"]);
|
||||
```
|
||||
|
||||
Table and column identifiers are validated, values are always parameterized, and empty bulk
|
||||
deletes are refused unless `{ all: true }` is explicit.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/db",
|
||||
"version": "0.8.18",
|
||||
"version": "0.8.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -61,3 +61,5 @@ 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";
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { Db, ExecResult } from "./driver.ts";
|
||||
|
||||
export type SeedRow = Record<string, unknown>;
|
||||
export type SeedDatabase = Db | (() => Db);
|
||||
|
||||
export interface AddSeedOptions {
|
||||
conflict?: "error" | "ignore" | "replace";
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function database(value: SeedDatabase): Db {
|
||||
return typeof value === "function" ? value() : value;
|
||||
}
|
||||
|
||||
/** Insert one or many object rows with parameterized values. */
|
||||
export async function addSeedData(
|
||||
source: SeedDatabase,
|
||||
data: SeedRow | readonly SeedRow[],
|
||||
table: string,
|
||||
options: AddSeedOptions = {},
|
||||
): Promise<ExecResult[]> {
|
||||
const db = database(source);
|
||||
const target = identifier(table, "seed table");
|
||||
const rows = Array.isArray(data) ? data : [data];
|
||||
const results: ExecResult[] = [];
|
||||
const clause =
|
||||
options.conflict === "ignore"
|
||||
? " OR IGNORE"
|
||||
: options.conflict === "replace"
|
||||
? " OR REPLACE"
|
||||
: "";
|
||||
for (const row of rows) {
|
||||
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"));
|
||||
results.push(
|
||||
await db.exec(
|
||||
`INSERT${clause} INTO ${target} (${columns.join(", ")}) VALUES (${columns.map(() => "?").join(", ")})`,
|
||||
entries.map(([, value]) => value),
|
||||
),
|
||||
);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Delete matching seed rows. An empty match is refused unless `all` is explicit. */
|
||||
export async function removeSeedData(
|
||||
source: SeedDatabase,
|
||||
match: SeedRow,
|
||||
table: string,
|
||||
options: { all?: boolean } = {},
|
||||
): Promise<ExecResult> {
|
||||
const db = database(source);
|
||||
const target = identifier(table, "seed table");
|
||||
const entries = Object.entries(match);
|
||||
if (!entries.length && !options.all) {
|
||||
throw new TypeError("removeSeedData requires match fields or { all: true }");
|
||||
}
|
||||
const where = entries.length
|
||||
? ` WHERE ${entries.map(([column]) => `${identifier(column, "seed column")} = ?`).join(" AND ")}`
|
||||
: "";
|
||||
return db.exec(
|
||||
`DELETE FROM ${target}${where}`,
|
||||
entries.map(([, value]) => value),
|
||||
);
|
||||
}
|
||||
|
||||
/** Execute an application-owned parameterized seed statement. */
|
||||
export function runSeedQuery(
|
||||
source: SeedDatabase,
|
||||
query: string,
|
||||
data: readonly unknown[] = [],
|
||||
): Promise<ExecResult> {
|
||||
const db = database(source);
|
||||
if (!query.trim()) throw new TypeError("seed query cannot be empty");
|
||||
return db.exec(query, [...data]);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import type { Db } from "../src/driver.ts";
|
||||
import { addSeedData, removeSeedData, runSeedQuery } from "../src/seed.ts";
|
||||
|
||||
function fakeDb() {
|
||||
const calls: Array<{ sql: string; parameters: unknown[] }> = [];
|
||||
const db = {
|
||||
exec: async (sql: string, parameters: unknown[] = []) => {
|
||||
calls.push({ sql, parameters });
|
||||
return { changes: 1 };
|
||||
},
|
||||
} as unknown as Db;
|
||||
return { db, calls };
|
||||
}
|
||||
|
||||
test("seed helpers parameterize object rows and removals", async () => {
|
||||
const { db, calls } = fakeDb();
|
||||
await addSeedData(db, [{ code: "free", credits: 10 }, { code: "pro", credits: 20 }], "plans", {
|
||||
conflict: "ignore",
|
||||
});
|
||||
await removeSeedData(db, { code: "free" }, "plans");
|
||||
expect(calls[0]).toEqual({
|
||||
sql: "INSERT OR IGNORE INTO plans (code, credits) VALUES (?, ?)",
|
||||
parameters: ["free", 10],
|
||||
});
|
||||
expect(calls[2]).toEqual({ sql: "DELETE FROM plans WHERE code = ?", parameters: ["free"] });
|
||||
});
|
||||
|
||||
test("custom seed queries stay parameterized and unsafe identifiers are refused", async () => {
|
||||
const { db, calls } = fakeDb();
|
||||
await runSeedQuery(db, "UPDATE plans SET credits = ? WHERE code = ?", [50, "free"]);
|
||||
expect(calls[0]?.parameters).toEqual([50, "free"]);
|
||||
await expect(addSeedData(db, { id: 1 }, "plans; DROP TABLE plans")).rejects.toThrow(
|
||||
"safe SQL identifier",
|
||||
);
|
||||
await expect(removeSeedData(db, {}, "plans")).rejects.toThrow("match fields");
|
||||
});
|
||||
Reference in New Issue
Block a user