fix: close durable queue and runtime gaps
This commit is contained in:
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/cli",
|
"name": "@wrnexus/cli",
|
||||||
"version": "0.8.59",
|
"version": "0.8.60",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./workspace": "./src/workspace.ts"
|
"./workspace": "./src/workspace.ts",
|
||||||
|
"./test-setup": "./src/test-setup.ts"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"wrnexus": "src/index.ts"
|
"wrnexus": "src/index.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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ export interface TestCommandPlan {
|
|||||||
level?: TestLevel;
|
level?: TestLevel;
|
||||||
files: string[];
|
files: string[];
|
||||||
setup?: { command: string; args: string[] };
|
setup?: { command: string; args: string[] };
|
||||||
|
env?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shardFiles(files: string[], value?: string): string[] {
|
function shardFiles(files: string[], value?: string): string[] {
|
||||||
@@ -69,6 +70,9 @@ export function createTestPlan(appRoot: string, args: string[]): TestCommandPlan
|
|||||||
const root = resolve(appRoot);
|
const root = resolve(appRoot);
|
||||||
const level = args.find((value): value is TestLevel => TEST_LEVELS.includes(value as TestLevel));
|
const level = args.find((value): value is TestLevel => TEST_LEVELS.includes(value as TestLevel));
|
||||||
const watch = args.includes("--watch");
|
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 shard = args.find((value) => value.startsWith("--shard="))?.slice(8);
|
||||||
const browsers = (args.find((value) => value.startsWith("--browsers="))?.slice(11) ?? "chromium")
|
const browsers = (args.find((value) => value.startsWith("--browsers="))?.slice(11) ?? "chromium")
|
||||||
.split(",")
|
.split(",")
|
||||||
@@ -120,10 +124,18 @@ export function createTestPlan(appRoot: string, args: string[]): TestCommandPlan
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
command: process.execPath,
|
command: process.execPath,
|
||||||
args: ["test", ...(watch ? ["--watch"] : []), ...(pattern ? files : []), ...passthrough],
|
args: [
|
||||||
|
"test",
|
||||||
|
"--preload",
|
||||||
|
setupModule,
|
||||||
|
...(watch ? ["--watch"] : []),
|
||||||
|
...(pattern ? files : []),
|
||||||
|
...passthrough,
|
||||||
|
],
|
||||||
cwd: root,
|
cwd: root,
|
||||||
level,
|
level,
|
||||||
files,
|
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;
|
process.exitCode = 1;
|
||||||
return null;
|
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) {
|
if (plan.setup) {
|
||||||
const setup = spawn(plan.setup.command, plan.setup.args, { stdio: "inherit", cwd: plan.cwd });
|
const setup = spawn(plan.setup.command, plan.setup.args, { stdio: "inherit", cwd: plan.cwd });
|
||||||
setup.on("exit", (code, signal) => {
|
setup.on("exit", (code, signal) => {
|
||||||
|
|||||||
@@ -50,6 +50,15 @@ describe("test command planning", () => {
|
|||||||
test("keeps the unfiltered legacy command", async () => {
|
test("keeps the unfiltered legacy command", async () => {
|
||||||
const plan = createTestPlan(await fixture(), ["--watch"]);
|
const plan = createTestPlan(await fixture(), ["--watch"]);
|
||||||
expect(plan.level).toBeUndefined();
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/compiler",
|
"name": "@wrnexus/compiler",
|
||||||
"version": "0.8.20",
|
"version": "0.8.21",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1857,6 +1857,8 @@ ${declarations}
|
|||||||
${
|
${
|
||||||
visible.length
|
visible.length
|
||||||
? ` const __values = await Promise.all([${visible.map((entry) => `__load_${entry.name}()`).join(", ")}]);
|
? ` 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(", ")} };`
|
return { ${visible.map((entry, index) => `${JSON.stringify(entry.name)}: __values[${index}]`).join(", ")} };`
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,17 @@ test("named loads support memoized dependencies and deferred execution", () => {
|
|||||||
expect(code).toContain('return { "audit": __values[0] }');
|
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 { <p>Private</p> }
|
||||||
|
}`),
|
||||||
|
);
|
||||||
|
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", () => {
|
test("load dependency cycles and cross-phase server dependencies fail compilation", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
parse(
|
parse(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/dev-server",
|
"name": "@wrnexus/dev-server",
|
||||||
"version": "0.8.51",
|
"version": "0.8.52",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1960,6 +1960,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
|||||||
).getOrLoad(`route:${matched.route.raw}`, () => load(pageCtx));
|
).getOrLoad(`route:${matched.route.raw}`, () => load(pageCtx));
|
||||||
}
|
}
|
||||||
(pageCtx as Context & { data?: unknown }).data = data;
|
(pageCtx as Context & { data?: unknown }).data = data;
|
||||||
|
if (data instanceof Response) return data;
|
||||||
if (data && typeof data === "object") Object.assign(pageCtx, data);
|
if (data && typeof data === "object") Object.assign(pageCtx, data);
|
||||||
}
|
}
|
||||||
let body = await renderComponents(String(await component(pageCtx)), ctx.t, ctx.lang);
|
let body = await renderComponents(String(await component(pageCtx)), ctx.t, ctx.lang);
|
||||||
|
|||||||
@@ -32,6 +32,32 @@ test("server loader data is available to page rendering", async () => {
|
|||||||
expect(await response!.text()).toContain("Ada,Lin / 2");
|
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 () => {
|
test("HMR page synchronization initializes request-scoped loader caching", async () => {
|
||||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-hmr-load-runtime-"));
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-hmr-load-runtime-"));
|
||||||
roots.push(root);
|
roots.push(root);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/mail",
|
"name": "@wrnexus/mail",
|
||||||
"version": "0.8.1",
|
"version": "0.8.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -14,6 +14,39 @@ export interface MailDriver {
|
|||||||
test?(): Promise<{ ok: boolean; error?: string }>;
|
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<string>;
|
||||||
|
create(credential: string): MailDriver | Promise<MailDriver>;
|
||||||
|
/** 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<MailDriver> | 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<T = Record<string, unknown>> {
|
export interface MailTemplate<T = Record<string, unknown>> {
|
||||||
subject(data: T): string;
|
subject(data: T): string;
|
||||||
html?(data: T): string;
|
html?(data: T): string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { expect, test } from "bun:test";
|
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 () => {
|
test("mail templates render and sandbox blocks accidental recipients", async () => {
|
||||||
const sent: unknown[] = [];
|
const sent: unknown[] = [];
|
||||||
@@ -16,3 +16,32 @@ test("mail templates render and sandbox blocks accidental recipients", async ()
|
|||||||
expect(sent).toHaveLength(1);
|
expect(sent).toHaveLength(1);
|
||||||
await expect(mail.send({ to: "real@example.com", subject: "no" })).rejects.toThrow("SANDBOX");
|
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"]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function createQueue(options?: QueueOptions): Queue;
|
|||||||
| `pollMs` | `number` | `250` | Poll interval used once `start()` is called (ms). |
|
| `pollMs` | `number` | `250` | Poll interval used once `start()` is called (ms). |
|
||||||
| `onFailed` | `(job: Job, error: unknown) => void` | — | Called when a job exhausts its attempts. |
|
| `onFailed` | `(job: Job, error: unknown) => void` | — | Called when a job exhausts its attempts. |
|
||||||
| `concurrency` | `number` | unlimited | Maximum jobs claimed by one `drain()` call. |
|
| `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. |
|
| `now` | `() => number` | `Date.now` | Clock injection for deterministic tests. |
|
||||||
|
|
||||||
### `Queue`
|
### `Queue`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/queue",
|
"name": "@wrnexus/queue",
|
||||||
"version": "0.8.13",
|
"version": "0.8.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ function lazyStore(resolve: () => Promise<QueueStore>): QueueStore {
|
|||||||
return (await ready()).list(name);
|
return (await ready()).list(name);
|
||||||
},
|
},
|
||||||
async findByIdempotencyKey(name, key) {
|
async findByIdempotencyKey(name, key) {
|
||||||
return (await ready()).findByIdempotencyKey?.(name, key) ?? null;
|
return (await ready()).findByIdempotencyKey(name, key);
|
||||||
},
|
},
|
||||||
async size() {
|
async size() {
|
||||||
return (await ready()).size?.() ?? (await (await ready()).list()).length;
|
return (await ready()).size?.() ?? (await (await ready()).list()).length;
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ export interface QueueStore {
|
|||||||
remove(id: string): Promise<void>;
|
remove(id: string): Promise<void>;
|
||||||
due(now: number, limit: number): Promise<Job[]>;
|
due(now: number, limit: number): Promise<Job[]>;
|
||||||
list(name?: string): Promise<Job[]>;
|
list(name?: string): Promise<Job[]>;
|
||||||
findByIdempotencyKey?(name: string, key: string): Promise<Job | null>;
|
/** Indexed lookup; implementations must not scan the queue. */
|
||||||
|
findByIdempotencyKey(name: string, key: string): Promise<Job | null>;
|
||||||
size?(): Promise<number>;
|
size?(): Promise<number>;
|
||||||
claim?(id: string, worker: string, leaseUntil: number, now?: number): Promise<boolean>;
|
claim?(id: string, worker: string, leaseUntil: number, now?: number): Promise<boolean>;
|
||||||
release?(id: string, worker: string): Promise<void>;
|
release?(id: string, worker: string): Promise<void>;
|
||||||
@@ -175,7 +176,8 @@ export function createDurableQueue(options: DurableQueueOptions = {}): DurableQu
|
|||||||
const workerId = options.workerId?.trim() || `worker-${crypto.randomUUID()}`;
|
const workerId = options.workerId?.trim() || `worker-${crypto.randomUUID()}`;
|
||||||
const defaultMaxAttempts = positiveInteger(options.maxAttempts ?? 3, "queue maxAttempts");
|
const defaultMaxAttempts = positiveInteger(options.maxAttempts ?? 3, "queue maxAttempts");
|
||||||
const concurrency = positiveInteger(options.concurrency ?? 10, "queue concurrency");
|
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 leaseMs = positiveInteger(options.leaseMs ?? 30_000, "queue leaseMs");
|
||||||
const pollMs = positiveInteger(options.pollMs ?? 250, "queue pollMs");
|
const pollMs = positiveInteger(options.pollMs ?? 250, "queue pollMs");
|
||||||
let sequence = 0;
|
let sequence = 0;
|
||||||
@@ -280,12 +282,13 @@ export function createDurableQueue(options: DurableQueueOptions = {}): DurableQu
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (add.idempotencyKey) {
|
if (add.idempotencyKey) {
|
||||||
const existing = store.findByIdempotencyKey
|
const existing = await store.findByIdempotencyKey(name, add.idempotencyKey);
|
||||||
? await store.findByIdempotencyKey(name, add.idempotencyKey)
|
|
||||||
: (await store.list(name)).find((job) => job.idempotencyKey === add.idempotencyKey);
|
|
||||||
if (existing) return existing as Job<T>;
|
if (existing) return existing as Job<T>;
|
||||||
}
|
}
|
||||||
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`);
|
throw new Error(`WRN-QUEUE-CAPACITY: queue capacity of ${capacity} reached`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,19 @@ export function redisQueueStore(client: RedisQueueClient, prefix = "wrnexus:queu
|
|||||||
const jobs = `${prefix}:jobs`;
|
const jobs = `${prefix}:jobs`;
|
||||||
const due = `${prefix}:due`;
|
const due = `${prefix}:due`;
|
||||||
const key = (id: string) => `${prefix}:job:${id}`;
|
const key = (id: string) => `${prefix}:job:${id}`;
|
||||||
|
const idempotencyKey = (name: string, value: string) =>
|
||||||
|
`${prefix}:idempotency:${encodeURIComponent(name)}:${encodeURIComponent(value)}`;
|
||||||
return {
|
return {
|
||||||
async put(job) {
|
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));
|
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.sadd(jobs, job.id);
|
||||||
await client.zadd(due, job.runAt, job.id);
|
await client.zadd(due, job.runAt, job.id);
|
||||||
await client.del(`${prefix}:lease:${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;
|
return value ? (JSON.parse(value) as Job) : null;
|
||||||
},
|
},
|
||||||
async remove(id) {
|
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.srem(jobs, id);
|
||||||
await client.zrem(due, id);
|
await client.zrem(due, id);
|
||||||
},
|
},
|
||||||
@@ -55,13 +69,10 @@ export function redisQueueStore(client: RedisQueueClient, prefix = "wrnexus:queu
|
|||||||
.filter((job) => !name || job.name === name);
|
.filter((job) => !name || job.name === name);
|
||||||
},
|
},
|
||||||
async findByIdempotencyKey(name, idempotencyKey) {
|
async findByIdempotencyKey(name, idempotencyKey) {
|
||||||
const ids = await client.smembers(jobs);
|
const id = await client.get(
|
||||||
const values = await Promise.all(ids.map((id) => client.get(key(id))));
|
`${prefix}:idempotency:${encodeURIComponent(name)}:${encodeURIComponent(idempotencyKey)}`,
|
||||||
const found = values
|
);
|
||||||
.filter((value): value is string => value !== null)
|
return id ? this.get(id) : null;
|
||||||
.map((value) => JSON.parse(value) as Job)
|
|
||||||
.find((job) => job.name === name && job.idempotencyKey === idempotencyKey);
|
|
||||||
return found ?? null;
|
|
||||||
},
|
},
|
||||||
async size() {
|
async size() {
|
||||||
return (await client.smembers(jobs)).length;
|
return (await client.smembers(jobs)).length;
|
||||||
|
|||||||
@@ -260,6 +260,21 @@ test("durable queue supports capacity, cancellation, and shutdown", async () =>
|
|||||||
await expect(queue.add("work", {})).rejects.toThrow("WRN-QUEUE-CLOSED");
|
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 () => {
|
test("force shutdown aborts an active durable job", async () => {
|
||||||
const store = memoryQueueStore();
|
const store = memoryQueueStore();
|
||||||
const queue = createDurableQueue({ store });
|
const queue = createDurableQueue({ store });
|
||||||
|
|||||||
Reference in New Issue
Block a user