feat: add application productivity foundations
This commit is contained in:
@@ -292,7 +292,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.8.54",
|
||||
"version": "0.8.55",
|
||||
"bin": {
|
||||
"wrnexus": "src/index.ts",
|
||||
},
|
||||
@@ -342,14 +342,14 @@
|
||||
},
|
||||
"packages/csr": {
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.8.28",
|
||||
"version": "0.8.29",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/db": {
|
||||
"name": "@wrnexus/db",
|
||||
"version": "0.8.19",
|
||||
"version": "0.8.20",
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"typescript": "^6.0.3",
|
||||
@@ -393,9 +393,10 @@
|
||||
},
|
||||
"packages/encryption": {
|
||||
"name": "@wrnexus/encryption",
|
||||
"version": "0.8.9",
|
||||
"version": "0.8.10",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/db": "workspace:*",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
@@ -411,9 +412,11 @@
|
||||
},
|
||||
"packages/helpers": {
|
||||
"name": "@wrnexus/helpers",
|
||||
"version": "0.8.8",
|
||||
"version": "0.8.9",
|
||||
"dependencies": {
|
||||
"@wrnexus/authz": "workspace:*",
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/db": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/i18n": {
|
||||
@@ -482,6 +485,13 @@
|
||||
"vscode-html-languageservice": "^5.6.2",
|
||||
},
|
||||
},
|
||||
"packages/mail": {
|
||||
"name": "@wrnexus/mail",
|
||||
"version": "0.8.1",
|
||||
"dependencies": {
|
||||
"@wrnexus/queue": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/mcp": {
|
||||
"name": "@wrnexus/mcp",
|
||||
"version": "0.8.8",
|
||||
@@ -547,7 +557,7 @@
|
||||
},
|
||||
"packages/queue": {
|
||||
"name": "@wrnexus/queue",
|
||||
"version": "0.8.12",
|
||||
"version": "0.8.13",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/db": "workspace:*",
|
||||
@@ -575,7 +585,7 @@
|
||||
},
|
||||
"packages/reactive": {
|
||||
"name": "@wrnexus/reactive",
|
||||
"version": "0.8.8",
|
||||
"version": "0.8.9",
|
||||
},
|
||||
"packages/realtime": {
|
||||
"name": "@wrnexus/realtime",
|
||||
@@ -635,7 +645,7 @@
|
||||
},
|
||||
"packages/styles": {
|
||||
"name": "@wrnexus/styles",
|
||||
"version": "0.8.18",
|
||||
"version": "0.8.19",
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/plugin": "workspace:*",
|
||||
@@ -1036,6 +1046,8 @@
|
||||
|
||||
"@wrnexus/language-server": ["@wrnexus/language-server@workspace:packages/language-server"],
|
||||
|
||||
"@wrnexus/mail": ["@wrnexus/mail@workspace:packages/mail"],
|
||||
|
||||
"@wrnexus/managed-captcha-service": ["@wrnexus/managed-captcha-service@workspace:services/managed-captcha"],
|
||||
|
||||
"@wrnexus/mcp": ["@wrnexus/mcp@workspace:packages/mcp"],
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Application productivity kit
|
||||
|
||||
WrNexus applications should declare infrastructure once and keep only domain decisions locally.
|
||||
|
||||
## Included foundations
|
||||
|
||||
1. HTTP routes: `defineApiRoute`, `authorized`, `requireUser`, `requirePermission`, `requireParam`, `requireJson`, and `json`.
|
||||
2. CRUD resources: `defineResource` generates owned list/create/get/update/delete handlers.
|
||||
3. Seed data: `addSeedData`, `upsertSeedData`, `seedIfMissing`, `defineSeed`, `removeSeedData`, and `runSeedQuery`.
|
||||
4. Transactional delivery: `withOutbox`, `drainOutbox`, and `installOutboxSchema`.
|
||||
5. Job state transitions: `defineJobStateMachine`.
|
||||
6. SQLite/PostgreSQL/MySQL queue tables: configured storage plus `databaseQueueStore`.
|
||||
7. Queue operations: success/failed hooks, dashboard handler and queue CLI.
|
||||
8. Queue testing: `testQueue` with deterministic `runNext`/`runAll`.
|
||||
9. Workflows and compensation: durable workflows and `defineSaga`.
|
||||
10. Mail: `defineMail`, `defineMailTemplate`, sandbox allowlists and `queuedMail`.
|
||||
11. Encrypted settings: `defineSecretResource`.
|
||||
12. Ledgers: `defineLedger` with atomic debit and audit entries.
|
||||
13. Forms: `useForm` for values, errors, dirty/submitting state, validation and submission.
|
||||
14. Typed HTTP calls: `createApiClient`; generated OpenAPI/SDK clients remain available through `wrnexus api generate`.
|
||||
15. Full-stack testing: `createTestApp`, database rollback/factories, and queue test controls.
|
||||
16. Package migrations: queue/auth/authz packages contribute migrations through the plugin lifecycle.
|
||||
17. Auth/OAuth: auth and authz plugins own routes, middleware, roles and provider lifecycle.
|
||||
18. Code generation: `wrnexus generate resource <name>`.
|
||||
19. Incremental features: `wrnexus add queue|mail|seed|testing|resource`.
|
||||
20. Starter presets: `wrnexus create app --preset=saas|email` and `--features=...`.
|
||||
|
||||
## Recommended new-project flow
|
||||
|
||||
```sh
|
||||
wrnexus create acme --preset=saas
|
||||
cd acme && bun install
|
||||
wrnexus generate resource projects
|
||||
wrnexus add mail
|
||||
wrnexus db migrate
|
||||
wrnexus db seed
|
||||
wrnexus dev
|
||||
```
|
||||
|
||||
Use generated resources for ordinary owned CRUD. Use the outbox for business operations that
|
||||
change data and enqueue work together. Use sagas only when a workflow crosses systems that cannot
|
||||
share a transaction. Application code should retain product-specific policy; framework code owns
|
||||
storage, validation, lifecycle, retries, diagnostics and scaffolding.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.8.54",
|
||||
"version": "0.8.55",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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:`);
|
||||
|
||||
@@ -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]+/)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.8.28",
|
||||
"version": "0.8.29",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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();
|
||||
});
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,3 +160,5 @@ export type {
|
||||
DecryptedHttpBody,
|
||||
ReplayStore,
|
||||
} from "./http.ts";
|
||||
export { defineSecretResource } from "./resource.ts";
|
||||
export type { SecretResourceOptions } from "./resource.ts";
|
||||
|
||||
@@ -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]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 });
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/queue",
|
||||
"version": "0.8.12",
|
||||
"version": "0.8.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/reactive",
|
||||
"version": "0.8.8",
|
||||
"version": "0.8.9",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/styles",
|
||||
"version": "0.8.18",
|
||||
"version": "0.8.19",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user