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/cli",
"version": "0.8.54",
"version": "0.8.55",
"type": "module",
"main": "src/index.ts",
"exports": {
+35
View File
@@ -0,0 +1,35 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { runGenerateResource } from "./generate.ts";
const FEATURES: Record<string, Record<string, string>> = {
queue: {
"app/queues/example.ts": `import { defineQueue } from "@wrnexus/queue";\n\nexport default defineQueue({\n name: "example",\n jobs: {\n run: {\n run: async (data: { id: string }) => { console.log("job", data.id); },\n success: async (data) => { console.log("completed", data.id); },\n failed: async (data, error) => { console.error("failed", data.id, error); },\n },\n },\n});\n`,
},
mail: {
"app/lib/mail.ts": `import { defineMail } from "@wrnexus/mail";\n\nexport const mail = defineMail({\n from: process.env.MAIL_FROM,\n driver: { send: async () => { throw new Error("Configure a mail driver"); } },\n sandbox: { enabled: process.env.NODE_ENV !== "production", allowlist: [] },\n});\n`,
},
seed: {
"app/db/seed.ts": `import { addSeedData, defineSeed } from "@wrnexus/db";\n\nexport default defineSeed(async (db) => {\n await addSeedData(db, { name: "Example" }, "examples", { conflict: "ignore" });\n});\n`,
},
testing: {
"test/app.test.ts": `import { expect, test } from "bun:test";\nimport { createTestApp } from "@wrnexus/test";\n\ntest("app boots", async () => {\n const app = await createTestApp(import.meta.dir + "/..");\n expect((await app.fetch("/healthz")).ok).toBe(true);\n app.close();\n});\n`,
},
};
export function runAdd(root: string, feature: string | undefined, name?: string) {
if (feature === "resource") return runGenerateResource(root, name);
const files = feature ? FEATURES[feature] : undefined;
if (!files)
throw new Error(`Use wrnexus add ${Object.keys(FEATURES).join(" | ")} | resource <name>`);
for (const [relative, content] of Object.entries(files)) {
const target = join(resolve(root), relative);
if (existsSync(target)) {
console.log(`${relative} exists — skipped`);
continue;
}
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, content, "utf8");
console.log(`✓ Added ${relative}`);
}
}
+16 -2
View File
@@ -7,6 +7,7 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { runAdd } from "./add.ts";
import { AI_GUIDE, CLAUDE_MD } from "./ai-guide.ts";
import { currentCliVersion } from "./update-notifier.ts";
@@ -672,13 +673,26 @@ export function scaffoldApp(root: string, appName: string): void {
}
}
export function createApp(name: string): void {
export function createApp(name: string, options: readonly string[] = []): void {
if (!name) {
console.error("Usage: wrnexus create <app-name>");
process.exit(1);
}
scaffoldApp(resolve(process.cwd(), name), name);
const root = resolve(process.cwd(), name);
scaffoldApp(root, name);
const preset = options.find((value) => value.startsWith("--preset="))?.split("=")[1];
const requested =
options
.find((value) => value.startsWith("--features="))
?.split("=")[1]
?.split(",") ?? [];
const features = new Set([
...(preset === "saas" ? ["queue", "mail", "seed", "testing"] : []),
...(preset === "email" ? ["queue", "mail", "testing"] : []),
...requested,
]);
for (const feature of features) runAdd(root, feature);
console.log(`✓ Created ${name}`);
console.log(`\nNext steps:`);
+40
View File
@@ -30,6 +30,46 @@ export interface GeneratedFile {
content: string;
}
export function scaffoldResource(name: string): GeneratedFile[] {
const clean = name.replace(/[^A-Za-z0-9_-]/g, "").toLowerCase();
if (!clean) throw new TypeError("resource name is required");
const singular = clean.replace(/s$/, "");
const pascal = toPascalCase(singular);
return [
{
path: `schemas/${singular}.ts`,
content: `import { v } from "@wrnexus/validation";\n\nexport default v.object({\n name: v.string().min(1, "Required"),\n});\n`,
},
{
path: `resources/${clean}.ts`,
content: `import { getDb } from "@wrnexus/db";\nimport { defineResource } from "@wrnexus/helpers";\n\nexport default defineResource({\n name: "${clean}",\n db: getDb,\n table: "${clean}",\n owner: "owner_id",\n fields: ["name"],\n permissions: { read: "${singular}:read", write: "${singular}:write" },\n});\n`,
},
{
path: `api/${clean}.ts`,
content: `import resource from "../resources/${clean}.ts";\nexport const GET = resource.list;\nexport const POST = resource.create;\n`,
},
{
path: `api/${clean}/[id].ts`,
content: `import resource from "../../resources/${clean}.ts";\nexport const GET = resource.get;\nexport const PUT = resource.update;\nexport const DELETE = resource.remove;\n`,
},
{
path: `pages/${clean}.wrn`,
content: `page ${pascal}List {\n view {\n <h1>${pascal}</h1>\n <p>Generated resource page. Connect it to /api/${clean}.</p>\n }\n}\n`,
},
];
}
export function runGenerateResource(appRoot: string, name: string | undefined): void {
if (!name) throw new TypeError("Usage: wrnexus generate resource <name>");
for (const file of scaffoldResource(name)) {
const target = join(resolve(appRoot), "app", file.path);
if (existsSync(target)) continue;
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, file.content, "utf8");
console.log(`✓ Generated app/${file.path}`);
}
}
function toPascalCase(name: string): string {
return name
.split(/[^A-Za-z0-9]+/)
+21 -1
View File
@@ -167,7 +167,7 @@ async function main(): Promise<void> {
break;
}
case "create":
createApp(rest[0] ?? "");
createApp(rest.find((value) => !value.startsWith("--")) ?? "", rest);
break;
case "workspace": {
const { addWorkspaceApp, createWorkspace } = await import("./workspace.ts");
@@ -192,6 +192,11 @@ async function main(): Promise<void> {
}
case "generate":
case "g": {
if (rest[0] === "resource") {
const { runGenerateResource } = await import("./generate.ts");
runGenerateResource(".", rest[1]);
break;
}
if (rest[0] === "routes") {
const { regenerateRoutes } = await import("./routes.ts");
const n = regenerateRoutes(join(resolve(rest[1] ?? "."), "app"));
@@ -307,6 +312,21 @@ async function main(): Promise<void> {
if (!result.ok) process.exitCode = 1;
break;
}
case "queue": {
const values = rest.filter((value) => !value.startsWith("--"));
const { runQueueCommand } = await import("./queue-command.ts");
await runQueueCommand(values[2] ?? ".", values[0] ?? "status", [
values[1] ?? "",
...rest.filter((value) => value.startsWith("--")),
]);
break;
}
case "add": {
const values = rest.filter((value) => !value.startsWith("--"));
const { runAdd } = await import("./add.ts");
runAdd(values[2] ?? ".", values[0], values[1]);
break;
}
case "security": {
const values = rest.filter((value) => !value.startsWith("--"));
const { runSecurityCommand } = await import("./security-command.ts");
+86
View File
@@ -0,0 +1,86 @@
import { resolve } from "node:path";
import { loadAppConfig } from "@wrnexus/styles";
import { connectFromConfig } from "@wrnexus/db/connect";
import { installSqliteQueueSchema } from "@wrnexus/queue";
export async function runQueueCommand(root: string, command: string, args: readonly string[]) {
const appRoot = resolve(root);
const config = await loadAppConfig(appRoot, process.env.WRNEXUS_PROFILE);
const selected = config.queue?.databaseName ?? "default";
const dbConfig = selected === "default" ? config.db : config.databases?.[selected];
const effective =
config.queue?.storage === "database"
? dbConfig
: { driver: "sqlite" as const, url: "file:./.wrnexus/queue.sqlite" };
if (!effective || effective.driver === "mongo")
throw new Error(`Queue database '${selected}' is not configured as SQL.`);
const db = connectFromConfig(effective, appRoot);
if (db.driver.dialect !== "sqlite")
throw new Error("Queue CLI currently requires SQLite configured storage.");
const table = config.queue?.table ?? "wrnexus_jobs";
await installSqliteQueueSchema(db, table);
try {
const id = args.find((value) => !value.startsWith("--"));
if (command === "list") {
console.table(
await db.all(
`SELECT id,name,run_at,priority,lease_owner,lease_until FROM ${table} ORDER BY run_at`,
),
);
return;
}
if (command === "status") {
const pending = await db.one<{ total: number }>(`SELECT COUNT(*) AS total FROM ${table}`);
const history = await db.one<{ total: number }>(
`SELECT COUNT(*) AS total FROM ${table}_history`,
);
console.log(
JSON.stringify(
{ pending: Number(pending?.total ?? 0), history: Number(history?.total ?? 0) },
null,
2,
),
);
return;
}
if (!id && command !== "clear") throw new Error(`wrnexus queue ${command} requires a job id`);
if (command === "inspect") {
console.log(
JSON.stringify(await db.one(`SELECT * FROM ${table} WHERE id = ?`, [id]), null, 2),
);
return;
}
if (command === "cancel") {
await db.exec(`DELETE FROM ${table} WHERE id = ?`, [id]);
return;
}
if (command === "retry") {
const row = await db.one<{ payload: string }>(
`SELECT payload FROM ${table}_history WHERE id = ? AND state = 'failed'`,
[id],
);
if (!row) throw new Error(`Failed job '${id}' was not found.`);
const job = JSON.parse(row.payload) as {
name: string;
runAt: number;
priority: number;
idempotencyKey?: string;
};
job.runAt = Date.now();
await db.exec(
`INSERT INTO ${table} (id,name,payload,run_at,priority,idempotency_key) VALUES (?,?,?,?,?,?)`,
[id, job.name, JSON.stringify(job), job.runAt, job.priority, job.idempotencyKey ?? null],
);
return;
}
if (command === "clear" && args.includes("--completed")) {
await db.exec(`DELETE FROM ${table}_history WHERE state = 'completed'`);
return;
}
throw new Error(
"Use queue list | status | inspect <id> | retry <id> | cancel <id> | clear --completed",
);
} finally {
await db.close();
}
}
+13 -1
View File
@@ -1,5 +1,5 @@
import { test, expect } from "bun:test";
import { scaffold } from "../src/generate.ts";
import { scaffold, scaffoldResource } from "../src/generate.ts";
test("scaffold page: kebab file, PascalCase page name", () => {
const f = scaffold("page", "about-us");
@@ -32,3 +32,15 @@ test("scaffold strips extensions from the given name", () => {
expect(scaffold("page", "home.wrn").path).toBe("pages/home.wrn");
expect(scaffold("api", "ping.ts").path).toBe("api/ping.ts");
});
test("resource scaffold emits schema, handlers, routes and a page", () => {
const files = scaffoldResource("templates");
expect(files.map((file) => file.path)).toEqual([
"schemas/template.ts",
"resources/templates.ts",
"api/templates.ts",
"api/templates/[id].ts",
"pages/templates.wrn",
]);
expect(files[1]?.content).toContain("defineResource");
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.8.28",
"version": "0.8.29",
"type": "module",
"main": "src/index.ts",
"exports": {
+41
View File
@@ -0,0 +1,41 @@
export interface ApiRequest<Input = unknown> {
method?: string;
params?: Record<string, string | number>;
query?: Record<string, string | number | boolean | undefined>;
body?: Input;
}
export interface ApiClientOptions {
baseUrl?: string;
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
/** Runtime used by generated API clients; throws a typed response error on non-2xx results. */
export function createApiClient(options: ApiClientOptions = {}) {
const fetcher = options.fetch ?? globalThis.fetch;
return async function call<Output, Input = unknown>(
template: string,
request: ApiRequest<Input> = {},
): Promise<Output> {
let path = template;
for (const [name, value] of Object.entries(request.params ?? {})) {
path = path.replace(`[${name}]`, encodeURIComponent(String(value)));
}
const url = new URL(path, options.baseUrl ?? globalThis.location?.origin ?? "http://localhost");
for (const [name, value] of Object.entries(request.query ?? {}))
if (value !== undefined) url.searchParams.set(name, String(value));
const response = await fetcher(url, {
method: request.method ?? (request.body === undefined ? "GET" : "POST"),
credentials: "same-origin",
headers: request.body === undefined ? undefined : { "content-type": "application/json" },
body: request.body === undefined ? undefined : JSON.stringify(request.body),
});
const data = response.status === 204 ? undefined : await response.json();
if (!response.ok)
throw Object.assign(
new Error((data as { error?: string })?.error ?? `HTTP ${response.status}`),
{ status: response.status, data },
);
return data as Output;
};
}
+2
View File
@@ -16,6 +16,8 @@ export { REACTIVE_RUNTIME } from "./reactive-runtime.ts";
export { NAV_RUNTIME } from "./nav-runtime.ts";
export { REALTIME_RUNTIME } from "./realtime-runtime.ts";
export { ACTION_RUNTIME } from "./action-runtime.ts";
export { createApiClient } from "./api-client.ts";
export type { ApiClientOptions, ApiRequest } from "./api-client.ts";
const CONTROLLER_SECTIONS = ["PRIMARY", "UI", "PIN"] as const;
+23
View File
@@ -0,0 +1,23 @@
import { expect, test } from "bun:test";
import { createApiClient } from "../src/index.ts";
test("typed API client expands params, query and JSON input", async () => {
let request: Request | undefined;
const call = createApiClient({
baseUrl: "https://app.test",
fetch: async (input, init) => {
request = new Request(input, init);
return Response.json({ ok: true });
},
});
expect(
await call<{ ok: boolean }, { name: string }>("/api/users/[id]", {
method: "PUT",
params: { id: 7 },
query: { audit: true },
body: { name: "Ada" },
}),
).toEqual({ ok: true });
expect(request?.url).toBe("https://app.test/api/users/7?audit=true");
expect(await request?.json()).toEqual({ name: "Ada" });
});
+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();
});
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/encryption",
"version": "0.8.9",
"version": "0.8.10",
"private": true,
"type": "module",
"main": "./src/index.ts",
@@ -23,6 +23,7 @@
"typescript": "^6.0.3"
},
"dependencies": {
"@wrnexus/core": "workspace:*"
"@wrnexus/core": "workspace:*",
"@wrnexus/db": "workspace:*"
}
}
+2
View File
@@ -160,3 +160,5 @@ export type {
DecryptedHttpBody,
ReplayStore,
} from "./http.ts";
export { defineSecretResource } from "./resource.ts";
export type { SecretResourceOptions } from "./resource.ts";
+53
View File
@@ -0,0 +1,53 @@
import type { Db } from "@wrnexus/db";
import { decrypt, encrypt } from "./index.ts";
export interface SecretResourceOptions<T extends Record<string, unknown>> {
db: () => Db;
table: string;
key: string | (() => string);
encrypted: readonly (keyof T & string)[];
hidden?: readonly (keyof T & string)[];
id?: string;
}
const SAFE = /^[A-Za-z_][A-Za-z0-9_]*$/;
/** Encrypt selected database fields and guarantee hidden fields never leave `safe()`. */
export function defineSecretResource<T extends Record<string, unknown>>(
options: SecretResourceOptions<T>,
) {
const checked = (value: string) => {
if (!SAFE.test(value)) throw new TypeError(`Unsafe secret resource identifier: ${value}`);
return value;
};
const table = checked(options.table);
const id = checked(options.id ?? "id");
const encrypted = options.encrypted.map(checked);
const hidden = new Set((options.hidden ?? options.encrypted).map(checked));
const key = () => (typeof options.key === "function" ? options.key() : options.key);
return {
async seal(input: T): Promise<T> {
const output = { ...input };
const writable = output as Record<string, unknown>;
for (const field of encrypted)
if (writable[field] !== undefined)
writable[field] = await encrypt(String(writable[field]), key());
return output;
},
async open(input: T): Promise<T> {
const output = { ...input };
const writable = output as Record<string, unknown>;
for (const field of encrypted)
if (writable[field]) writable[field] = await decrypt(String(writable[field]), key());
return output;
},
safe(input: T): Partial<T> {
return Object.fromEntries(
Object.entries(input).filter(([field]) => !hidden.has(field)),
) as Partial<T>;
},
async find(value: string | number): Promise<T | null> {
return options.db().one<T>(`SELECT * FROM ${table} WHERE ${id} = ?`, [value]);
},
};
}
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/helpers",
"version": "0.8.8",
"version": "0.8.9",
"private": true,
"type": "module",
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
@@ -9,6 +9,8 @@
".": "./src/index.ts"
},
"dependencies": {
"@wrnexus/authz": "workspace:*",
"@wrnexus/db": "workspace:*",
"@wrnexus/core": "workspace:*"
}
}
+90
View File
@@ -0,0 +1,90 @@
import type { Context } from "@wrnexus/core";
import { can } from "@wrnexus/authz";
export class HttpError extends Error {
constructor(
public readonly status: number,
message: string,
public readonly details?: unknown,
) {
super(message);
this.name = "HttpError";
}
}
export function subjectId(ctx: Pick<Context, "user">): string {
return String((ctx.user as { id?: unknown } | undefined)?.id ?? "").trim();
}
export function requireUser(ctx: Pick<Context, "user">): { id: string; user: unknown } {
const id = subjectId(ctx);
if (!id) throw new HttpError(401, "Unauthorized");
return { id, user: ctx.user };
}
export function requireParam(
ctx: Pick<Context, "params">,
name: string,
options: { integer?: boolean; positive?: boolean } = {},
): string | number {
const raw = String(ctx.params[name] ?? "").trim();
if (!raw) throw new HttpError(400, `Route parameter '${name}' is required.`);
if (!options.integer) return raw;
const value = Number(raw);
if (!Number.isInteger(value) || (options.positive && value < 1)) {
throw new HttpError(400, `Route parameter '${name}' must be a positive integer.`);
}
return value;
}
export async function requireJson<T = Record<string, unknown>>(
ctx: Pick<Context, "req">,
): Promise<T> {
try {
return (await ctx.req.json()) as T;
} catch {
throw new HttpError(400, "Request body must be valid JSON.");
}
}
export async function requirePermission(
ctx: Context,
permission: string,
resource?: unknown,
): Promise<void> {
if (!(await can(ctx, permission, resource))) throw new HttpError(403, "Forbidden");
}
export const json = {
ok: <T>(value: T) => Response.json(value),
created: <T>(value: T) => Response.json(value, { status: 201 }),
noContent: () => new Response(null, { status: 204 }),
error: (status: number, message: string, details?: unknown) =>
Response.json({ error: message, ...(details === undefined ? {} : { details }) }, { status }),
};
export type ApiHandler = (ctx: Context) => Response | Promise<Response>;
/** Convert thrown HttpError values into the framework's standard JSON error shape. */
export function defineApiRoute(handler: ApiHandler): ApiHandler {
return async (ctx) => {
try {
return await handler(ctx);
} catch (error) {
if (error instanceof HttpError) return json.error(error.status, error.message, error.details);
throw error;
}
};
}
export function authorized(
permission: string,
handler: ApiHandler,
resource?: (ctx: Context) => unknown | Promise<unknown>,
): ApiHandler {
return defineApiRoute(async (ctx) => {
requireUser(ctx);
await requirePermission(ctx, permission, await resource?.(ctx));
return handler(ctx);
});
}
+14
View File
@@ -157,3 +157,17 @@ export {
once,
} from "./resilience.ts";
export type { RetryOptions } from "./resilience.ts";
export {
HttpError,
authorized,
defineApiRoute,
json,
requireJson,
requireParam,
requirePermission,
requireUser,
subjectId,
} from "./http.ts";
export type { ApiHandler } from "./http.ts";
export { defineResource } from "./resource.ts";
export type { ResourceDefinition, ResourceHandlers } from "./resource.ts";
+118
View File
@@ -0,0 +1,118 @@
import type { Context } from "@wrnexus/core";
import type { Db } from "@wrnexus/db";
import { can } from "@wrnexus/authz";
import { HttpError, defineApiRoute, json, requireJson, requireParam, requireUser } from "./http.ts";
const SAFE = /^[A-Za-z_][A-Za-z0-9_]*$/;
const ident = (value: string) => {
if (!SAFE.test(value)) throw new TypeError(`Unsafe resource identifier: ${value}`);
return value;
};
export interface ResourceDefinition<T extends Record<string, unknown>> {
name: string;
db: () => Db;
table: string;
id?: string;
owner?: string;
fields: readonly (keyof T & string)[];
permissions?: { read?: string; readAny?: string; write?: string; writeAny?: string };
validate?: (input: unknown, mode: "create" | "update") => T | Promise<T>;
serialize?: (row: T) => unknown;
}
export interface ResourceHandlers {
list(ctx: Context): Response | Promise<Response>;
create(ctx: Context): Response | Promise<Response>;
get(ctx: Context): Response | Promise<Response>;
update(ctx: Context): Response | Promise<Response>;
remove(ctx: Context): Response | Promise<Response>;
}
/** Build conventional owned CRUD handlers from one checked resource declaration. */
export function defineResource<T extends Record<string, unknown>>(
definition: ResourceDefinition<T>,
): ResourceHandlers {
const table = ident(definition.table);
const id = ident(definition.id ?? "id");
const owner = definition.owner ? ident(definition.owner) : undefined;
const fields = definition.fields.map(ident);
const shape = (row: T) => definition.serialize?.(row) ?? row;
const authorize = async (ctx: Context, action: "read" | "write", row?: T) => {
const subject = requireUser(ctx).id;
const permission = definition.permissions?.[action];
const any = definition.permissions?.[`${action}Any`];
if (any && (await can(ctx, any))) return subject;
if (
permission &&
!(await can(ctx, permission, owner ? { ownerId: row?.[owner] ?? subject } : row))
) {
throw new HttpError(403, "Forbidden");
}
if (owner && row && String(row[owner]) !== subject) throw new HttpError(403, "Forbidden");
return subject;
};
const find = async (ctx: Context) => {
const value = requireParam(ctx, id, { integer: true, positive: true });
const row = await definition.db().one<T>(`SELECT * FROM ${table} WHERE ${id} = ?`, [value]);
if (!row) throw new HttpError(404, `${definition.name} not found`);
return row;
};
return {
list: defineApiRoute(async (ctx) => {
const subject = await authorize(ctx, "read");
const seeAll =
definition.permissions?.readAny && (await can(ctx, definition.permissions.readAny));
const rows = await definition
.db()
.all<T>(
`SELECT * FROM ${table}${owner && !seeAll ? ` WHERE ${owner} = ?` : ""} ORDER BY ${id} DESC`,
owner && !seeAll ? [subject] : [],
);
return json.ok({ [definition.name]: rows.map(shape) });
}),
create: defineApiRoute(async (ctx) => {
const subject = await authorize(ctx, "write");
const input = definition.validate
? await definition.validate(await requireJson(ctx), "create")
: await requireJson<T>(ctx);
const values = Object.fromEntries(
fields.filter((key) => key in input).map((key) => [key, input[key]]),
);
if (owner) values[owner] = subject;
const columns = Object.keys(values).map(ident);
const result = await definition.db().exec(
`INSERT INTO ${table} (${columns.join(", ")}) VALUES (${columns.map(() => "?").join(", ")})`,
columns.map((key) => values[key]),
);
return json.created({ ok: true, id: result.lastInsertId });
}),
get: defineApiRoute(async (ctx) => {
const row = await find(ctx);
await authorize(ctx, "read", row);
return json.ok({ [definition.name.replace(/s$/, "")]: shape(row) });
}),
update: defineApiRoute(async (ctx) => {
const row = await find(ctx);
await authorize(ctx, "write", row);
const input = definition.validate
? await definition.validate(await requireJson(ctx), "update")
: await requireJson<T>(ctx);
const keys = fields.filter((key) => key in input);
if (!keys.length) throw new HttpError(400, "No writable fields supplied.");
await definition
.db()
.exec(`UPDATE ${table} SET ${keys.map((key) => `${key} = ?`).join(", ")} WHERE ${id} = ?`, [
...keys.map((key) => input[key]),
row[id],
]);
return json.ok({ ok: true });
}),
remove: defineApiRoute(async (ctx) => {
const row = await find(ctx);
await authorize(ctx, "write", row);
await definition.db().exec(`DELETE FROM ${table} WHERE ${id} = ?`, [row[id]]);
return json.ok({ ok: true });
}),
};
}
+5
View File
@@ -0,0 +1,5 @@
# @wrnexus/mail
Driver-neutral transactional mail with templates, sandbox allowlists, connection tests and
optional durable queue producers. Applications supply an SMTP/API driver, while policy and
message rendering stay portable.
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@wrnexus/mail",
"version": "0.8.1",
"private": true,
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@wrnexus/queue": "workspace:*"
}
}
+88
View File
@@ -0,0 +1,88 @@
import type { DefinedJob } from "@wrnexus/queue";
export interface MailMessage {
from?: string;
to: string | readonly string[];
subject: string;
html?: string;
text?: string;
headers?: Record<string, string>;
}
export interface MailDriver {
send(message: MailMessage): Promise<unknown>;
test?(): Promise<{ ok: boolean; error?: string }>;
}
export interface MailTemplate<T = Record<string, unknown>> {
subject(data: T): string;
html?(data: T): string;
text?(data: T): string;
}
export interface MailOptions {
driver: MailDriver;
from?: string;
sandbox?: { enabled?: boolean; allowlist?: readonly string[] };
}
export function defineMail(options: MailOptions) {
const allowed = new Set(
(options.sandbox?.allowlist ?? []).map((value) => value.trim().toLowerCase()),
);
const assertAllowed = (recipients: readonly string[]) => {
if (!options.sandbox?.enabled) return;
const denied = recipients.find((recipient) => !allowed.has(recipient.trim().toLowerCase()));
if (denied) throw new Error(`WRN-MAIL-SANDBOX: recipient '${denied}' is not allow-listed`);
};
return {
async send(message: MailMessage) {
const recipients = typeof message.to === "string" ? [message.to] : [...message.to];
assertAllowed(recipients);
return options.driver.send({ ...message, from: message.from ?? options.from });
},
async render<T>(template: MailTemplate<T>, data: T, to: string | readonly string[]) {
return {
from: options.from,
to,
subject: template.subject(data),
html: template.html?.(data),
text: template.text?.(data),
} satisfies MailMessage;
},
test: () => options.driver.test?.() ?? Promise.resolve({ ok: true }),
};
}
export function defineMailTemplate<T>(template: MailTemplate<T>): MailTemplate<T> {
return template;
}
export function interpolateTemplate(source: string, data: Record<string, unknown>): string {
return source.replace(/\{\{\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\}\}/g, (_match, path: string) => {
let value: unknown = data;
for (const part of path.split("."))
value =
value && typeof value === "object" ? (value as Record<string, unknown>)[part] : undefined;
return value == null ? "" : String(value);
});
}
export function storedMailTemplate(template: {
subject: string;
html?: string;
text?: string;
}): MailTemplate<Record<string, unknown>> {
return {
subject: (data) => interpolateTemplate(template.subject, data),
html:
template.html === undefined ? undefined : (data) => interpolateTemplate(template.html!, data),
text:
template.text === undefined ? undefined : (data) => interpolateTemplate(template.text!, data),
};
}
/** Queue a fully rendered message without coupling the mail package to one queue backend. */
export function queuedMail<T extends MailMessage>(job: Pick<DefinedJob<T>, "add">) {
return { send: (message: T) => job.add(message) };
}
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "bun:test";
import { defineMail, defineMailTemplate } from "../src/index.ts";
test("mail templates render and sandbox blocks accidental recipients", async () => {
const sent: unknown[] = [];
const mail = defineMail({
from: "hello@example.com",
sandbox: { enabled: true, allowlist: ["dev@example.com"] },
driver: { send: async (message) => void sent.push(message) },
});
const welcome = defineMailTemplate<{ name: string }>({
subject: ({ name }) => `Hello ${name}`,
html: ({ name }) => `<b>${name}</b>`,
});
await mail.send(await mail.render(welcome, { name: "Ada" }, "dev@example.com"));
expect(sent).toHaveLength(1);
await expect(mail.send({ to: "real@example.com", subject: "no" })).rejects.toThrow("SANDBOX");
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/queue",
"version": "0.8.12",
"version": "0.8.13",
"private": true,
"type": "module",
"main": "src/index.ts",
+37
View File
@@ -0,0 +1,37 @@
import type { DurableQueue } from "./durable.ts";
import { queueDashboardSnapshot, renderQueueDashboard } from "./scheduler.ts";
export interface QueueAdminOptions {
queues: Record<string, DurableQueue>;
authorize?: (request: Request) => boolean | Promise<boolean>;
}
/** Framework-neutral protected queue dashboard/API handler, mountable at any route. */
export function createQueueAdminHandler(options: QueueAdminOptions) {
return async (request: Request): Promise<Response> => {
if (options.authorize && !(await options.authorize(request))) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
const url = new URL(request.url);
const name = url.searchParams.get("queue") ?? Object.keys(options.queues)[0];
const queue = name ? options.queues[name] : undefined;
if (!queue) return Response.json({ error: "Queue not found" }, { status: 404 });
const id = url.searchParams.get("id");
if (request.method === "POST" && id) {
const action = url.searchParams.get("action");
const changed =
action === "retry"
? await queue.retry(id)
: action === "cancel"
? await queue.cancel(id)
: false;
return Response.json({ ok: changed });
}
const snapshot = await queueDashboardSnapshot(queue);
if (url.searchParams.get("format") === "json")
return Response.json({ queue: name, ...snapshot });
return new Response(renderQueueDashboard(snapshot), {
headers: { "content-type": "text/html; charset=utf-8" },
});
};
}
+34 -17
View File
@@ -2,6 +2,7 @@ import { getDb, hasDb, registerDb, type Db } from "@wrnexus/db";
import { connectFromConfig } from "@wrnexus/db/connect";
import { memoryQueueStore, type QueueStore } from "./durable.ts";
import { installSqliteQueueSchema, sqliteQueueStore } from "./sqlite.ts";
import { databaseQueueStore, installDatabaseQueueSchema } from "./database.ts";
export type QueueStorage = "sqlite" | "database" | "memory";
@@ -29,21 +30,39 @@ function lazyStore(resolve: () => Promise<QueueStore>): QueueStore {
let pending: Promise<QueueStore> | undefined;
const ready = () => (pending ??= resolve());
return {
async put(job) { return (await ready()).put(job); },
async get(id) { return (await ready()).get(id); },
async remove(id) { return (await ready()).remove(id); },
async due(now, limit) { return (await ready()).due(now, limit); },
async list(name) { return (await ready()).list(name); },
async put(job) {
return (await ready()).put(job);
},
async get(id) {
return (await ready()).get(id);
},
async remove(id) {
return (await ready()).remove(id);
},
async due(now, limit) {
return (await ready()).due(now, limit);
},
async list(name) {
return (await ready()).list(name);
},
async findByIdempotencyKey(name, key) {
return (await ready()).findByIdempotencyKey?.(name, key) ?? null;
},
async size() { return (await ready()).size?.() ?? (await (await ready()).list()).length; },
async size() {
return (await ready()).size?.() ?? (await (await ready()).list()).length;
},
async claim(id, worker, leaseUntil, now) {
return (await ready()).claim?.(id, worker, leaseUntil, now) ?? true;
},
async release(id, worker) { await (await ready()).release?.(id, worker); },
async archive(record) { await (await ready()).archive?.(record); },
async history(id) { return (await ready()).history?.(id) ?? []; },
async release(id, worker) {
await (await ready()).release?.(id, worker);
},
async archive(record) {
await (await ready()).archive?.(record);
},
async history(id) {
return (await ready()).history?.(id) ?? [];
},
};
}
@@ -72,15 +91,13 @@ async function resolveConfiguredStore(): Promise<QueueStore> {
}
}
if (db.driver.dialect !== "sqlite") {
throw new Error(
`WRN-QUEUE-DATABASE: configured queue storage currently requires SQLite; ` +
`database '${databaseName}' uses ${db.driver.dialect}. Pass a custom queue store for that driver.`,
);
}
const table = config.table ?? "wrnexus_jobs";
await installSqliteQueueSchema(db, table);
return sqliteQueueStore(db, table);
if (db.driver.dialect === "sqlite") {
await installSqliteQueueSchema(db, table);
return sqliteQueueStore(db, table);
}
await installDatabaseQueueSchema(db, table);
return databaseQueueStore(db, table);
}
/** Lazy global store used by defineQueue; runtime config is read on first operation. */
+158
View File
@@ -0,0 +1,158 @@
import type { Db } from "@wrnexus/db";
import type { Job } from "./index.ts";
import type { QueueJobRecord, QueueStore } from "./durable.ts";
const SAFE = /^[A-Za-z_][A-Za-z0-9_]*$/;
const check = (value: string) => {
if (!SAFE.test(value)) throw new TypeError(`Invalid queue table: ${value}`);
return value;
};
export async function installDatabaseQueueSchema(db: Db, table = "wrnexus_jobs") {
const target = check(table);
await db.exec(`CREATE TABLE IF NOT EXISTS ${target} (
id VARCHAR(191) PRIMARY KEY, name VARCHAR(191) NOT NULL, payload TEXT NOT NULL,
run_at BIGINT NOT NULL, priority INTEGER NOT NULL DEFAULT 0,
idempotency_key VARCHAR(191), lease_owner VARCHAR(191), lease_until BIGINT
)`);
await db.exec(`CREATE TABLE IF NOT EXISTS ${target}_history (
id VARCHAR(191) PRIMARY KEY, name VARCHAR(191) NOT NULL, state VARCHAR(32) NOT NULL,
payload TEXT NOT NULL, error TEXT, finished_at BIGINT NOT NULL
)`);
for (const statement of [
`CREATE INDEX ${target}_due ON ${target} (run_at, priority)`,
`CREATE UNIQUE INDEX ${target}_idempotency ON ${target} (name, idempotency_key)`,
]) {
try {
await db.exec(statement);
} catch (error) {
if (!/exist|duplicate|already/i.test(String(error))) throw error;
}
}
}
export function databaseQueueStore(db: Db, table = "wrnexus_jobs"): QueueStore {
const target = check(table);
type Row = { payload: string };
const decode = (row: Row) => JSON.parse(row.payload) as Job;
return {
async put(job) {
if (db.driver.dialect === "mysql") {
await db.exec(
`INSERT INTO ${target} (id,name,payload,run_at,priority,idempotency_key,lease_owner,lease_until)
VALUES (?,?,?,?,?,?,NULL,NULL) ON DUPLICATE KEY UPDATE name=VALUES(name),payload=VALUES(payload),run_at=VALUES(run_at),priority=VALUES(priority),lease_owner=NULL,lease_until=NULL`,
[
job.id,
job.name,
JSON.stringify(job),
job.runAt,
job.priority,
job.idempotencyKey ?? null,
],
);
} else {
await db.exec(
`INSERT INTO ${target} (id,name,payload,run_at,priority,idempotency_key,lease_owner,lease_until)
VALUES (?,?,?,?,?,?,NULL,NULL) ON CONFLICT(id) DO UPDATE SET name=excluded.name,payload=excluded.payload,run_at=excluded.run_at,priority=excluded.priority,lease_owner=NULL,lease_until=NULL`,
[
job.id,
job.name,
JSON.stringify(job),
job.runAt,
job.priority,
job.idempotencyKey ?? null,
],
);
}
},
async get(id) {
const row = await db.one<Row>(`SELECT payload FROM ${target} WHERE id = ?`, [id]);
return row ? decode(row) : null;
},
async remove(id) {
await db.exec(`DELETE FROM ${target} WHERE id = ?`, [id]);
},
async due(now, limit) {
return (
await db.all<Row>(
`SELECT payload FROM ${target} WHERE run_at <= ? AND (lease_until IS NULL OR lease_until < ?) ORDER BY priority DESC,run_at LIMIT ?`,
[now, now, limit],
)
).map(decode);
},
async list(name) {
return (
await db.all<Row>(
`SELECT payload FROM ${target}${name ? " WHERE name = ?" : ""} ORDER BY run_at`,
name ? [name] : [],
)
).map(decode);
},
async findByIdempotencyKey(name, key) {
const row = await db.one<Row>(
`SELECT payload FROM ${target} WHERE name = ? AND idempotency_key = ?`,
[name, key],
);
return row ? decode(row) : null;
},
async size() {
return Number(
(await db.one<{ total: number | string }>(`SELECT COUNT(*) AS total FROM ${target}`))
?.total ?? 0,
);
},
async claim(id, worker, leaseUntil, now = Date.now()) {
return (
(
await db.exec(
`UPDATE ${target} SET lease_owner=?,lease_until=? WHERE id=? AND (lease_until IS NULL OR lease_until < ?)`,
[worker, leaseUntil, id, now],
)
).changes > 0
);
},
async release(id, worker) {
await db.exec(
`UPDATE ${target} SET lease_owner=NULL,lease_until=NULL WHERE id=? AND lease_owner=?`,
[id, worker],
);
},
async archive(record) {
const values = [
record.job.id,
record.job.name,
record.state,
JSON.stringify(record.job),
record.error ?? null,
record.finishedAt,
];
if (db.driver.dialect === "mysql")
await db.exec(
`INSERT INTO ${target}_history (id,name,state,payload,error,finished_at) VALUES (?,?,?,?,?,?) ON DUPLICATE KEY UPDATE state=VALUES(state),payload=VALUES(payload),error=VALUES(error),finished_at=VALUES(finished_at)`,
values,
);
else
await db.exec(
`INSERT INTO ${target}_history (id,name,state,payload,error,finished_at) VALUES (?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET state=excluded.state,payload=excluded.payload,error=excluded.error,finished_at=excluded.finished_at`,
values,
);
},
async history(id) {
const rows = await db.all<{
state: QueueJobRecord["state"];
payload: string;
error: string | null;
finished_at: number;
}>(
`SELECT state,payload,error,finished_at FROM ${target}_history${id ? " WHERE id = ?" : ""} ORDER BY finished_at DESC`,
id ? [id] : [],
);
return rows.map((row) => ({
job: JSON.parse(row.payload),
state: row.state,
error: row.error ?? undefined,
finishedAt: Number(row.finished_at),
}));
},
};
}
+12
View File
@@ -317,6 +317,7 @@ export type {
} from "./defined.ts";
export { installSqliteQueueSchema, sqliteQueueSchema, sqliteQueueStore } from "./sqlite.ts";
export type { SqliteQueueClient } from "./sqlite.ts";
export { databaseQueueStore, installDatabaseQueueSchema } from "./database.ts";
export { configureQueueStorage, configuredQueueStore, queueStorageConfig } from "./configured.ts";
export type { QueueStorage, QueueStorageConfig } from "./configured.ts";
export { redisQueueStore, postgresQueueStore, POSTGRES_QUEUE_SCHEMA } from "./stores.ts";
@@ -340,4 +341,15 @@ export type {
} from "./workflow.ts";
export { subjectQueue } from "./subject.ts";
export type { SubjectJob, SubjectQueue } from "./subject.ts";
export {
defineJobStateMachine,
defineSaga,
drainOutbox,
installOutboxSchema,
testQueue,
withOutbox,
} from "./patterns.ts";
export { createQueueAdminHandler } from "./admin.ts";
export type { QueueAdminOptions } from "./admin.ts";
export type { JobStateMachineOptions, OutboxEntry, OutboxWriter, SagaStep } from "./patterns.ts";
import { createExecutionContext, type ExecutionContext } from "@wrnexus/core";
+171
View File
@@ -0,0 +1,171 @@
import type { Db } from "@wrnexus/db";
import type { AddOptions, Job } from "./index.ts";
import type { DefinedJob } from "./defined.ts";
import type { DurableQueue } from "./durable.ts";
const SAFE = /^[A-Za-z_][A-Za-z0-9_]*$/;
const ident = (value: string) => {
if (!SAFE.test(value)) throw new TypeError(`Unsafe queue identifier: ${value}`);
return value;
};
export interface OutboxEntry<T = unknown> {
id: number;
queue: string;
payload: T;
options?: AddOptions;
}
export interface OutboxWriter {
enqueue<T>(job: Pick<DefinedJob<T>, "name">, data: T, options?: AddOptions): Promise<void>;
}
export async function installOutboxSchema(db: Db, table = "wrnexus_outbox"): Promise<void> {
const target = ident(table);
const id =
db.driver.dialect === "postgres"
? "BIGSERIAL PRIMARY KEY"
: "INTEGER PRIMARY KEY AUTOINCREMENT";
await db.exec(`CREATE TABLE IF NOT EXISTS ${target} (
id ${id}, queue_name TEXT NOT NULL, payload TEXT NOT NULL,
options TEXT, created_at BIGINT NOT NULL, dispatched_at BIGINT
)`);
await db.exec(`CREATE INDEX IF NOT EXISTS ${target}_pending ON ${target} (dispatched_at, id)`);
}
/** Commit business writes and durable enqueue intents in the same database transaction. */
export async function withOutbox<T>(
db: Db,
operation: (context: { db: Db; enqueue: OutboxWriter["enqueue"] }) => Promise<T>,
table = "wrnexus_outbox",
): Promise<T> {
await installOutboxSchema(db, table);
const target = ident(table);
return db.tx((transaction) =>
operation({
db: transaction,
enqueue: async (job, data, options) => {
await transaction.exec(
`INSERT INTO ${target} (queue_name,payload,options,created_at,dispatched_at) VALUES (?,?,?,?,NULL)`,
[job.name, JSON.stringify(data), options ? JSON.stringify(options) : null, Date.now()],
);
},
}),
);
}
/** Dispatch committed outbox rows. A crash before marking simply retries idempotently. */
export async function drainOutbox(
db: Db,
resolve: (
queueName: string,
) => { add(data: unknown, options?: AddOptions): Promise<Job> } | undefined,
options: { table?: string; limit?: number } = {},
): Promise<number> {
const target = ident(options.table ?? "wrnexus_outbox");
await installOutboxSchema(db, target);
const rows = await db.all<{
id: number;
queue_name: string;
payload: string;
options: string | null;
}>(
`SELECT id,queue_name,payload,options FROM ${target} WHERE dispatched_at IS NULL ORDER BY id LIMIT ?`,
[options.limit ?? 100],
);
let dispatched = 0;
for (const row of rows) {
const producer = resolve(row.queue_name);
if (!producer) continue;
const addOptions = row.options ? (JSON.parse(row.options) as AddOptions) : {};
await producer.add(JSON.parse(row.payload), {
idempotencyKey: `outbox:${row.id}`,
...addOptions,
});
await db.exec(`UPDATE ${target} SET dispatched_at = ? WHERE id = ? AND dispatched_at IS NULL`, [
Date.now(),
row.id,
]);
dispatched++;
}
return dispatched;
}
export interface JobStateMachineOptions {
db: () => Db;
table: string;
idColumn?: string;
stateColumn?: string;
states: { queued: string; running: string; success: string; failed: string };
}
export function defineJobStateMachine(options: JobStateMachineOptions) {
const table = ident(options.table);
const id = ident(options.idColumn ?? "id");
const state = ident(options.stateColumn ?? "status");
const transition = async (recordId: string | number, from: string, to: string) =>
(
await options
.db()
.exec(`UPDATE ${table} SET ${state} = ? WHERE ${id} = ? AND ${state} = ?`, [
to,
recordId,
from,
])
).changes > 0;
return {
queue: (recordId: string | number) =>
transition(recordId, options.states.failed, options.states.queued),
claim: (recordId: string | number) =>
transition(recordId, options.states.queued, options.states.running),
complete: (recordId: string | number) =>
transition(recordId, options.states.running, options.states.success),
fail: (recordId: string | number) =>
transition(recordId, options.states.running, options.states.failed),
transition,
};
}
export function testQueue(queue: DurableQueue) {
return {
add: queue.add.bind(queue),
runNext: () => queue.drain(),
async runAll(limit = 1_000) {
let total = 0;
while (total < limit) {
const count = await queue.drain();
if (!count) break;
total += count;
}
return total;
},
get: queue.get.bind(queue),
status: queue.status.bind(queue),
history: queue.history.bind(queue),
shutdown: queue.shutdown.bind(queue),
};
}
export interface SagaStep<T> {
name: string;
run(value: T): void | Promise<void>;
compensate?(value: T, cause: unknown): void | Promise<void>;
}
export function defineSaga<T>(definition: { name: string; steps: readonly SagaStep<T>[] }) {
return {
name: definition.name,
async run(value: T): Promise<void> {
const completed: SagaStep<T>[] = [];
try {
for (const step of definition.steps) {
await step.run(value);
completed.push(step);
}
} catch (cause) {
for (const step of completed.reverse()) await step.compensate?.(value, cause);
throw cause;
}
},
};
}
+50
View File
@@ -0,0 +1,50 @@
import { expect, test } from "bun:test";
import { createDb } from "@wrnexus/db";
import { sqlite } from "@wrnexus/db/sqlite";
import {
createDurableQueue,
defineSaga,
drainOutbox,
memoryQueueStore,
testQueue,
withOutbox,
} from "../src/index.ts";
test("outbox commits intents and deterministic queue helpers dispatch them", async () => {
const db = createDb(sqlite(":memory:"));
const queue = createDurableQueue({ store: memoryQueueStore() });
const seen: number[] = [];
queue.process("email:send", async (job) => void seen.push((job.data as { id: number }).id));
await withOutbox(db, async ({ enqueue }) => enqueue({ name: "email:send" } as never, { id: 7 }));
expect(
await drainOutbox(db, (name) =>
name === "email:send"
? { add: (data, options) => queue.add(name, data, options) }
: undefined,
),
).toBe(1);
expect(await testQueue(queue).runAll()).toBe(1);
expect(seen).toEqual([7]);
});
test("sagas compensate completed steps in reverse order", async () => {
const calls: string[] = [];
const saga = defineSaga({
name: "send",
steps: [
{
name: "reserve",
run: () => void calls.push("reserve"),
compensate: () => void calls.push("refund"),
},
{
name: "queue",
run: () => {
throw new Error("down");
},
},
],
});
await expect(saga.run({})).rejects.toThrow("down");
expect(calls).toEqual(["reserve", "refund"]);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/reactive",
"version": "0.8.8",
"version": "0.8.9",
"type": "module",
"main": "src/index.ts",
"exports": {
+75
View File
@@ -0,0 +1,75 @@
import { computed, signal, type ReadonlySignal, type Signal } from "./signal.ts";
export interface FormState<T extends Record<string, unknown>> {
values: Signal<T>;
errors: Signal<Partial<Record<keyof T, string>>>;
submitting: Signal<boolean>;
dirty: ReadonlySignal<boolean>;
field<K extends keyof T>(
name: K,
): {
value: T[K];
onInput(payload: { value?: unknown; checked?: boolean }): void;
};
set<K extends keyof T>(name: K, value: T[K]): void;
reset(next?: T): void;
validate(): Promise<boolean>;
submit<R>(operation: (values: T) => R | Promise<R>): Promise<R>;
}
export function useForm<T extends Record<string, unknown>>(
initial: T,
validator?: (
values: T,
) => Partial<Record<keyof T, string>> | Promise<Partial<Record<keyof T, string>>>,
): FormState<T> {
let baseline = structuredClone(initial);
const values = signal(structuredClone(initial));
const errors = signal<Partial<Record<keyof T, string>>>({});
const submitting = signal(false);
const dirty = computed(() => JSON.stringify(values.get()) !== JSON.stringify(baseline));
const set = <K extends keyof T>(name: K, value: T[K]) => {
values.set({ ...values.get(), [name]: value });
if (errors.get()[name]) errors.set({ ...errors.get(), [name]: undefined });
};
const validate = async () => {
errors.set((await validator?.(values.get())) ?? {});
return Object.values(errors.get()).every((value) => !value);
};
return {
values,
errors,
submitting,
dirty,
field(name) {
return {
value: values.get()[name],
onInput(payload) {
const current = values.get()[name];
set(
name,
(typeof current === "boolean"
? Boolean(payload.checked)
: payload.value) as T[typeof name],
);
},
};
},
set,
reset(next = initial) {
baseline = structuredClone(next);
values.set(structuredClone(next));
errors.set({});
},
validate,
async submit(operation) {
if (!(await validate())) throw new Error("WRN-FORM-INVALID");
submitting.set(true);
try {
return await operation(values.get());
} finally {
submitting.set(false);
}
},
};
}
+2
View File
@@ -17,6 +17,8 @@ export {
createTimeline,
urlSignal,
} from "./advanced.ts";
export { useForm } from "./form.ts";
export type { FormState } from "./form.ts";
export type {
HistorySignal,
ReactiveContext,
+14
View File
@@ -0,0 +1,14 @@
import { expect, test } from "bun:test";
import { useForm } from "../src/index.ts";
test("form state binds fields, validates, submits and resets", async () => {
const form = useForm({ name: "", active: false }, (value) => ({
name: value.name ? undefined : "Required",
}));
form.field("name").onInput({ value: "Ada" });
form.field("active").onInput({ checked: true });
expect(form.dirty.get()).toBe(true);
expect(await form.submit(async (value) => value.name)).toBe("Ada");
form.reset();
expect(form.values.get()).toEqual({ name: "", active: false });
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/styles",
"version": "0.8.18",
"version": "0.8.19",
"type": "module",
"main": "src/index.ts",
"exports": {
+7
View File
@@ -305,6 +305,13 @@ export interface AppConfig {
storage?: "sqlite" | "database" | "memory";
databaseName?: string;
table?: string;
dashboard?: { enabled?: boolean; path?: string; permission?: string };
};
/** Driver-neutral mail defaults consumed by @wrnexus/mail/application drivers. */
mail?: {
from?: string;
queue?: string | boolean;
sandbox?: { enabled?: boolean; allowlist?: string[] };
};
/**
* File-upload storage. Declare named stores (local dir or S3-compatible),