diff --git a/packages/cli/package.json b/packages/cli/package.json index 861565ca..395448ce 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,11 +1,12 @@ { "name": "@wrnexus/cli", - "version": "0.8.59", + "version": "0.8.60", "type": "module", "main": "src/index.ts", "exports": { ".": "./src/index.ts", - "./workspace": "./src/workspace.ts" + "./workspace": "./src/workspace.ts", + "./test-setup": "./src/test-setup.ts" }, "bin": { "wrnexus": "src/index.ts" diff --git a/packages/cli/src/test-setup.ts b/packages/cli/src/test-setup.ts new file mode 100644 index 00000000..bbdefbec --- /dev/null +++ b/packages/cli/src/test-setup.ts @@ -0,0 +1,14 @@ +import { registerDb, setDb } from "@wrnexus/db"; +import { connectFromConfig } from "@wrnexus/db/connect"; +import { loadAppConfig, loadEnv } from "@wrnexus/styles"; + +const root = process.env.WRNEXUS_TEST_ROOT; +if (root) { + const profile = process.env.WRNEXUS_PROFILE || "test"; + loadEnv(root, profile); + const config = await loadAppConfig(root, profile); + if (config.db) setDb(connectFromConfig(config.db, root)); + for (const [name, database] of Object.entries(config.databases ?? {})) { + registerDb(name, connectFromConfig(database, root)); + } +} diff --git a/packages/cli/src/test.ts b/packages/cli/src/test.ts index 0631eaa6..cb3796e8 100644 --- a/packages/cli/src/test.ts +++ b/packages/cli/src/test.ts @@ -20,6 +20,7 @@ export interface TestCommandPlan { level?: TestLevel; files: string[]; setup?: { command: string; args: string[] }; + env?: Record; } function shardFiles(files: string[], value?: string): string[] { @@ -69,6 +70,9 @@ export function createTestPlan(appRoot: string, args: string[]): TestCommandPlan const root = resolve(appRoot); const level = args.find((value): value is TestLevel => TEST_LEVELS.includes(value as TestLevel)); const watch = args.includes("--watch"); + const profile = args.find((value) => value.startsWith("--profile="))?.slice(10) || "test"; + const setupSource = join(import.meta.dir, "test-setup.ts"); + const setupModule = existsSync(setupSource) ? setupSource : join(import.meta.dir, "test-setup.js"); const shard = args.find((value) => value.startsWith("--shard="))?.slice(8); const browsers = (args.find((value) => value.startsWith("--browsers="))?.slice(11) ?? "chromium") .split(",") @@ -120,10 +124,18 @@ export function createTestPlan(appRoot: string, args: string[]): TestCommandPlan ); return { command: process.execPath, - args: ["test", ...(watch ? ["--watch"] : []), ...(pattern ? files : []), ...passthrough], + args: [ + "test", + "--preload", + setupModule, + ...(watch ? ["--watch"] : []), + ...(pattern ? files : []), + ...passthrough, + ], cwd: root, level, files, + env: { WRNEXUS_TEST_ROOT: root, WRNEXUS_PROFILE: profile }, }; } @@ -136,7 +148,12 @@ export function runTests(appRoot: string, args: string[]): ChildProcess | null { process.exitCode = 1; return null; } - const launch = () => spawn(plan.command, plan.args, { stdio: "inherit", cwd: plan.cwd }); + const launch = () => + spawn(plan.command, plan.args, { + stdio: "inherit", + cwd: plan.cwd, + env: { ...process.env, ...plan.env }, + }); if (plan.setup) { const setup = spawn(plan.setup.command, plan.setup.args, { stdio: "inherit", cwd: plan.cwd }); setup.on("exit", (code, signal) => { diff --git a/packages/cli/test/test-command.test.ts b/packages/cli/test/test-command.test.ts index 1bb0efc5..d2d6e48a 100644 --- a/packages/cli/test/test-command.test.ts +++ b/packages/cli/test/test-command.test.ts @@ -50,6 +50,15 @@ describe("test command planning", () => { test("keeps the unfiltered legacy command", async () => { const plan = createTestPlan(await fixture(), ["--watch"]); expect(plan.level).toBeUndefined(); - expect(plan.args).toEqual(["test", "--watch"]); + expect(plan.args[0]).toBe("test"); + expect(plan.args).toContain("--preload"); + expect(plan.args.at(-1)).toBe("--watch"); + expect(plan.env?.WRNEXUS_PROFILE).toBe("test"); + }); + + test("passes the selected profile to automatic database setup", async () => { + const plan = createTestPlan(await fixture(), ["--profile=ci"]); + expect(plan.env?.WRNEXUS_PROFILE).toBe("ci"); + expect(plan.args).not.toContain("--profile=ci"); }); }); diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 850dd516..73da421d 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/compiler", - "version": "0.8.20", + "version": "0.8.21", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/compiler/src/codegen.ts b/packages/compiler/src/codegen.ts index 8f4e3fbd..9889c240 100644 --- a/packages/compiler/src/codegen.ts +++ b/packages/compiler/src/codegen.ts @@ -1857,6 +1857,8 @@ ${declarations} ${ visible.length ? ` const __values = await Promise.all([${visible.map((entry) => `__load_${entry.name}()`).join(", ")}]); + const __response = __values.find((value) => value instanceof Response); + if (__response) return __response; return { ${visible.map((entry, index) => `${JSON.stringify(entry.name)}: __values[${index}]`).join(", ")} };` : "" } diff --git a/packages/compiler/test/async-boundary.test.ts b/packages/compiler/test/async-boundary.test.ts index c430149a..dea5cf28 100644 --- a/packages/compiler/test/async-boundary.test.ts +++ b/packages/compiler/test/async-boundary.test.ts @@ -56,6 +56,17 @@ test("named loads support memoized dependencies and deferred execution", () => { expect(code).toContain('return { "audit": __values[0] }'); }); +test("named server loads propagate redirect responses instead of wrapping them as data", () => { + const code = generate( + parse(`page Private { + load server gate { return Response.redirect("/login", 303) } + view {

Private

} + }`), + ); + expect(code).toContain("__values.find((value) => value instanceof Response)"); + expect(code).toContain("if (__response) return __response"); +}); + test("load dependency cycles and cross-phase server dependencies fail compilation", () => { expect(() => parse( diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index 39f391b6..bc554268 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.8.51", + "version": "0.8.52", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/dev-server/src/runtime.ts b/packages/dev-server/src/runtime.ts index ba2a2c16..05379c32 100644 --- a/packages/dev-server/src/runtime.ts +++ b/packages/dev-server/src/runtime.ts @@ -1960,6 +1960,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers { ).getOrLoad(`route:${matched.route.raw}`, () => load(pageCtx)); } (pageCtx as Context & { data?: unknown }).data = data; + if (data instanceof Response) return data; if (data && typeof data === "object") Object.assign(pageCtx, data); } let body = await renderComponents(String(await component(pageCtx)), ctx.t, ctx.lang); diff --git a/packages/dev-server/test/load-runtime.test.ts b/packages/dev-server/test/load-runtime.test.ts index 744a3c43..e9a04bfc 100644 --- a/packages/dev-server/test/load-runtime.test.ts +++ b/packages/dev-server/test/load-runtime.test.ts @@ -32,6 +32,32 @@ test("server loader data is available to page rendering", async () => { expect(await response!.text()).toContain("Ada,Lin / 2"); }); +test("a server loader can return a redirect Response", async () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-load-redirect-")); + roots.push(root); + const app = join(root, "app"); + mkdirSync(join(app, "pages"), { recursive: true }); + writeFileSync(join(app, "pages/private.ts"), "export default () => '';\n"); + const handlers = createHandlers({ + mode: "production", + hmr: false, + router: buildRouter(app), + loadModule: async () => ({ + __wrnexusLoad: async () => Response.redirect("https://example.test/login", 303), + default: () => { + throw new Error("redirected pages must not render"); + }, + }), + getMiddleware: async () => [], + assets: { serve: async () => null }, + } satisfies RuntimeDeps); + const response = await handlers.fetch(new Request("https://example.test/private"), { + upgrade: () => false, + }); + expect(response?.status).toBe(303); + expect(response?.headers.get("location")).toBe("https://example.test/login"); +}); + test("HMR page synchronization initializes request-scoped loader caching", async () => { const root = mkdtempSync(join(tmpdir(), "wrnexus-hmr-load-runtime-")); roots.push(root); diff --git a/packages/mail/package.json b/packages/mail/package.json index d0135f62..8648252d 100644 --- a/packages/mail/package.json +++ b/packages/mail/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/mail", - "version": "0.8.1", + "version": "0.8.2", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/mail/src/index.ts b/packages/mail/src/index.ts index 4e528038..1bfa50bb 100644 --- a/packages/mail/src/index.ts +++ b/packages/mail/src/index.ts @@ -14,6 +14,39 @@ export interface MailDriver { test?(): Promise<{ ok: boolean; error?: string }>; } +export interface SealedMailDriverOptions { + /** Ciphertext stored by the application; plaintext is never retained here. */ + sealedCredential: string; + unseal(value: string): Promise; + create(credential: string): MailDriver | Promise; + /** Cache the initialized driver. Defaults to true. */ + cache?: boolean; +} + +/** + * Lazily opens a sealed credential only at the transport boundary. This keeps + * encryption policy and SMTP/vendor choice independent while preventing every + * app from rebuilding the same decrypt-on-send lifecycle. + */ +export function sealedMailDriver(options: SealedMailDriverOptions): MailDriver { + let cached: Promise | undefined; + const create = async () => options.create(await options.unseal(options.sealedCredential)); + const driver = () => + options.cache === false ? create() : (cached ??= create().catch((error) => { + cached = undefined; + throw error; + })); + return { + async send(message) { + return (await driver()).send(message); + }, + async test() { + const resolved = await driver(); + return resolved.test?.() ?? { ok: true }; + }, + }; +} + export interface MailTemplate> { subject(data: T): string; html?(data: T): string; diff --git a/packages/mail/test/mail.test.ts b/packages/mail/test/mail.test.ts index 768e0a0f..3039d1ad 100644 --- a/packages/mail/test/mail.test.ts +++ b/packages/mail/test/mail.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { defineMail, defineMailTemplate } from "../src/index.ts"; +import { defineMail, defineMailTemplate, sealedMailDriver } from "../src/index.ts"; test("mail templates render and sandbox blocks accidental recipients", async () => { const sent: unknown[] = []; @@ -16,3 +16,32 @@ test("mail templates render and sandbox blocks accidental recipients", async () expect(sent).toHaveLength(1); await expect(mail.send({ to: "real@example.com", subject: "no" })).rejects.toThrow("SANDBOX"); }); + +test("sealed drivers decrypt lazily once and delegate send/test", async () => { + let opens = 0; + const sent: string[] = []; + const driver = sealedMailDriver({ + sealedCredential: "ciphertext", + async unseal(value) { + opens++; + expect(value).toBe("ciphertext"); + return "plaintext-secret"; + }, + create(credential) { + expect(credential).toBe("plaintext-secret"); + return { + async send(message) { + sent.push(message.subject); + }, + async test() { + return { ok: true }; + }, + }; + }, + }); + expect(opens).toBe(0); + await driver.send({ to: "safe@example.test", subject: "one" }); + expect(await driver.test?.()).toEqual({ ok: true }); + expect(opens).toBe(1); + expect(sent).toEqual(["one"]); +}); diff --git a/packages/queue/README.md b/packages/queue/README.md index 110bf367..02ca93ab 100644 --- a/packages/queue/README.md +++ b/packages/queue/README.md @@ -44,7 +44,7 @@ function createQueue(options?: QueueOptions): Queue; | `pollMs` | `number` | `250` | Poll interval used once `start()` is called (ms). | | `onFailed` | `(job: Job, error: unknown) => void` | — | Called when a job exhausts its attempts. | | `concurrency` | `number` | unlimited | Maximum jobs claimed by one `drain()` call. | -| `capacity` | `number` | unlimited | Maximum queued plus active jobs before adds reject. | +| `capacity` | `number` | unlimited | Optional maximum queued jobs; omitted queues never scan merely to enforce a hidden cap. | | `now` | `() => number` | `Date.now` | Clock injection for deterministic tests. | ### `Queue` diff --git a/packages/queue/package.json b/packages/queue/package.json index 27363b1a..76fdfc17 100644 --- a/packages/queue/package.json +++ b/packages/queue/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/queue", - "version": "0.8.13", + "version": "0.8.14", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/queue/src/configured.ts b/packages/queue/src/configured.ts index f442b28e..fc2004c5 100644 --- a/packages/queue/src/configured.ts +++ b/packages/queue/src/configured.ts @@ -46,7 +46,7 @@ function lazyStore(resolve: () => Promise): QueueStore { return (await ready()).list(name); }, async findByIdempotencyKey(name, key) { - return (await ready()).findByIdempotencyKey?.(name, key) ?? null; + return (await ready()).findByIdempotencyKey(name, key); }, async size() { return (await ready()).size?.() ?? (await (await ready()).list()).length; diff --git a/packages/queue/src/durable.ts b/packages/queue/src/durable.ts index 83e705f6..d077ec33 100644 --- a/packages/queue/src/durable.ts +++ b/packages/queue/src/durable.ts @@ -14,7 +14,8 @@ export interface QueueStore { remove(id: string): Promise; due(now: number, limit: number): Promise; list(name?: string): Promise; - findByIdempotencyKey?(name: string, key: string): Promise; + /** Indexed lookup; implementations must not scan the queue. */ + findByIdempotencyKey(name: string, key: string): Promise; size?(): Promise; claim?(id: string, worker: string, leaseUntil: number, now?: number): Promise; release?(id: string, worker: string): Promise; @@ -175,7 +176,8 @@ export function createDurableQueue(options: DurableQueueOptions = {}): DurableQu const workerId = options.workerId?.trim() || `worker-${crypto.randomUUID()}`; const defaultMaxAttempts = positiveInteger(options.maxAttempts ?? 3, "queue maxAttempts"); const concurrency = positiveInteger(options.concurrency ?? 10, "queue concurrency"); - const capacity = positiveInteger(options.capacity ?? 10_000, "queue capacity"); + const capacity = options.capacity ?? Number.POSITIVE_INFINITY; + if (capacity !== Number.POSITIVE_INFINITY) positiveInteger(capacity, "queue capacity"); const leaseMs = positiveInteger(options.leaseMs ?? 30_000, "queue leaseMs"); const pollMs = positiveInteger(options.pollMs ?? 250, "queue pollMs"); let sequence = 0; @@ -280,12 +282,13 @@ export function createDurableQueue(options: DurableQueueOptions = {}): DurableQu } if (add.idempotencyKey) { - const existing = store.findByIdempotencyKey - ? await store.findByIdempotencyKey(name, add.idempotencyKey) - : (await store.list(name)).find((job) => job.idempotencyKey === add.idempotencyKey); + const existing = await store.findByIdempotencyKey(name, add.idempotencyKey); if (existing) return existing as Job; } - if ((store.size ? await store.size() : (await store.list()).length) >= capacity) { + if ( + capacity !== Number.POSITIVE_INFINITY && + (store.size ? await store.size() : (await store.list()).length) >= capacity + ) { throw new Error(`WRN-QUEUE-CAPACITY: queue capacity of ${capacity} reached`); } diff --git a/packages/queue/src/stores.ts b/packages/queue/src/stores.ts index 73c803ba..d24aeec2 100644 --- a/packages/queue/src/stores.ts +++ b/packages/queue/src/stores.ts @@ -23,9 +23,19 @@ export function redisQueueStore(client: RedisQueueClient, prefix = "wrnexus:queu const jobs = `${prefix}:jobs`; const due = `${prefix}:due`; const key = (id: string) => `${prefix}:job:${id}`; + const idempotencyKey = (name: string, value: string) => + `${prefix}:idempotency:${encodeURIComponent(name)}:${encodeURIComponent(value)}`; return { async put(job) { + const previousValue = await client.get(key(job.id)); + const previous = previousValue ? (JSON.parse(previousValue) as Job) : null; + if (previous?.idempotencyKey && previous.idempotencyKey !== job.idempotencyKey) { + await client.del(idempotencyKey(previous.name, previous.idempotencyKey)); + } await client.set(key(job.id), JSON.stringify(job)); + if (job.idempotencyKey) { + await client.set(idempotencyKey(job.name, job.idempotencyKey), job.id); + } await client.sadd(jobs, job.id); await client.zadd(due, job.runAt, job.id); await client.del(`${prefix}:lease:${job.id}`); @@ -35,7 +45,11 @@ export function redisQueueStore(client: RedisQueueClient, prefix = "wrnexus:queu return value ? (JSON.parse(value) as Job) : null; }, async remove(id) { - await client.del(key(id), `${prefix}:lease:${id}`); + const value = await client.get(key(id)); + const job = value ? (JSON.parse(value) as Job) : null; + const keys = [key(id), `${prefix}:lease:${id}`]; + if (job?.idempotencyKey) keys.push(idempotencyKey(job.name, job.idempotencyKey)); + await client.del(...keys); await client.srem(jobs, id); await client.zrem(due, id); }, @@ -55,13 +69,10 @@ export function redisQueueStore(client: RedisQueueClient, prefix = "wrnexus:queu .filter((job) => !name || job.name === name); }, async findByIdempotencyKey(name, idempotencyKey) { - const ids = await client.smembers(jobs); - const values = await Promise.all(ids.map((id) => client.get(key(id)))); - const found = values - .filter((value): value is string => value !== null) - .map((value) => JSON.parse(value) as Job) - .find((job) => job.name === name && job.idempotencyKey === idempotencyKey); - return found ?? null; + const id = await client.get( + `${prefix}:idempotency:${encodeURIComponent(name)}:${encodeURIComponent(idempotencyKey)}`, + ); + return id ? this.get(id) : null; }, async size() { return (await client.smembers(jobs)).length; diff --git a/packages/queue/test/queue.test.ts b/packages/queue/test/queue.test.ts index 0b7aa18a..7df51b58 100644 --- a/packages/queue/test/queue.test.ts +++ b/packages/queue/test/queue.test.ts @@ -260,6 +260,21 @@ test("durable queue supports capacity, cancellation, and shutdown", async () => await expect(queue.add("work", {})).rejects.toThrow("WRN-QUEUE-CLOSED"); }); +test("durable queue has no implicit capacity scan or 10k ceiling", async () => { + const base = memoryQueueStore(); + let sizeCalls = 0; + const store = { + ...base, + async size() { + sizeCalls++; + return 10_001; + }, + }; + const queue = createDurableQueue({ store }); + await expect(queue.add("work", {})).resolves.toMatchObject({ name: "work" }); + expect(sizeCalls).toBe(0); +}); + test("force shutdown aborts an active durable job", async () => { const store = memoryQueueStore(); const queue = createDurableQueue({ store });