Add searchable and flattened export, history re-export and diagnostics self-test

issueAndArchive keeps an issued invoice even when rendering or archiving
fails (Issued - not archived, with a Render & archive action). The export
menu offers a searchable PDF or a 300 DPI image-only PDF whose metadata is
the invoice number alone; flatten failures offer Retry or searchable export
and never silently lower the resolution. History re-exports the original
as issued by default and warns when a re-render's layout fingerprint
differs. The render client gains a FIFO assemble lane that previews cannot
supersede. A Diagnostics panel runs fixtures in the real worker and
compares against goldens generated from Node.
This commit is contained in:
2026-10-04 06:11:23 +05:30
parent 7162dac6e7
commit f1b7502e9d
30 changed files with 2727 additions and 154 deletions
+114 -32
View File
@@ -1,4 +1,4 @@
import type { RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs";
import type { AssembleJob, AssembleResult, RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs";
/** A newer render call replaced this one before it finished (or before it started). */
export class Superseded extends Error {
@@ -44,6 +44,12 @@ export interface WorkerLike {
export interface RenderClient {
/** Resolves with the newest request's result; an older call rejects with Superseded. */
render(job: RenderJob): Promise<RenderResult>;
/**
* Wraps JPEG pages into an image-only PDF. This is a separate lane from `render`: renders are
* latest-wins, but an assemble is an export step that must complete, so it is never superseded by
* a render (or by another assemble). It waits for the job in flight and runs before any queued render.
*/
assemble(job: AssembleJob): Promise<AssembleResult>;
dispose(): void;
readonly mode: "worker" | "main";
}
@@ -57,23 +63,38 @@ export interface RenderClientOptions {
const defaultWorkerFactory = (): WorkerLike =>
new Worker(new URL("./render.worker.ts", import.meta.url), { type: "module" }) as unknown as WorkerLike;
interface Slot {
interface RenderSlot {
kind: "render";
job: RenderJob;
resolve: (r: RenderResult) => void;
reject: (e: unknown) => void;
}
interface Running extends Slot {
interface AssembleSlot {
kind: "assemble";
job: AssembleJob;
resolve: (r: AssembleResult) => void;
reject: (e: unknown) => void;
}
type Slot = RenderSlot | AssembleSlot;
type Running = Slot & {
/** Its caller was already told; the worker cannot be interrupted, so the job just runs out. */
superseded: boolean;
timer: ReturnType<typeof setTimeout>;
}
};
const requestFor = (slot: Slot): WorkerRequest =>
slot.kind === "render" ? { type: "render", job: slot.job } : { type: "assemble-images", job: slot.job };
/**
* Renders in a module worker. Only the newest request matters: a new call rejects the one in flight
* Renders in a module worker. Only the newest render matters: a new call rejects the render in flight
* (the worker keeps going, react-pdf cannot be aborted) and replaces any queued one, so after the
* running job finishes at most one job, the newest, runs. The handshake is the worker's `ready`
* message; a start-up error rejects with WorkerUnavailable and there is no timer-based fallback.
* running job finishes at most one render, the newest, runs. Assemble jobs are the exception: they
* queue in order, are never superseded, and go ahead of a queued render. The handshake is the
* worker's `ready` message; a start-up error rejects with WorkerUnavailable and there is no
* timer-based fallback.
*/
export function createRenderClient(opts: RenderClientOptions = {}): RenderClient {
const { workerFactory = defaultWorkerFactory, timeoutMs = 45000 } = opts;
@@ -82,7 +103,8 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
let unavailable: WorkerUnavailable | null = null;
let disposed = false;
let running: Running | null = null;
let pending: Slot | null = null;
let pending: RenderSlot | null = null;
const assembles: AssembleSlot[] = [];
const stopWorker = () => {
const w = worker;
@@ -107,16 +129,25 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
unavailable = err;
stopWorker();
settleRunning((s) => s.reject(err));
rejectQueued(err);
};
const rejectQueued = (err: unknown) => {
pending?.reject(err);
pending = null;
for (const a of assembles.splice(0)) a.reject(err);
};
const pump = () => {
if (!ready || !worker || running || !pending || disposed) return;
const slot = pending;
pending = null;
if (!ready || !worker || running || disposed) return;
let slot: Slot | null = assembles.shift() ?? null;
if (!slot) {
slot = pending;
pending = null;
}
if (!slot) return;
running = { ...slot, superseded: false, timer: setTimeout(onTimeout, timeoutMs) };
worker.postMessage({ type: "render", job: slot.job });
worker.postMessage(requestFor(slot));
};
const spawn = () => {
@@ -136,8 +167,12 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
ready = true;
pump();
} else if (msg.type === "result") {
if (running?.job.id !== msg.result.id) return;
settleRunning((s) => s.resolve(msg.result));
if (running?.kind !== "render" || running.job.id !== msg.result.id) return;
settleRunning((s) => (s as RenderSlot).resolve(msg.result));
pump();
} else if (msg.type === "assembled") {
if (running?.kind !== "assemble" || running.job.id !== msg.result.id) return;
settleRunning((s) => (s as AssembleSlot).resolve(msg.result));
pump();
} else if (msg.id === null) {
if (!ready) fail(new WorkerUnavailable(msg.message));
@@ -170,11 +205,20 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
if (unavailable) return Promise.reject(unavailable);
return new Promise<RenderResult>((resolve, reject) => {
pending?.reject(new Superseded());
if (running && !running.superseded) {
if (running && running.kind === "render" && !running.superseded) {
running.superseded = true;
running.reject(new Superseded());
}
pending = { job, resolve, reject };
pending = { kind: "render", job, resolve, reject };
if (!worker) spawn();
pump();
});
},
assemble(job) {
if (disposed) return Promise.reject(new Error("The render client was disposed"));
if (unavailable) return Promise.reject(unavailable);
return new Promise<AssembleResult>((resolve, reject) => {
assembles.push({ kind: "assemble", job, resolve, reject });
if (!worker) spawn();
pump();
});
@@ -184,8 +228,7 @@ export function createRenderClient(opts: RenderClientOptions = {}): RenderClient
disposed = true;
const err = new Error("The render client was disposed");
settleRunning((s) => s.reject(err));
pending?.reject(err);
pending = null;
rejectQueued(err);
stopWorker();
},
};
@@ -212,20 +255,41 @@ const runOnMainThread: MainExecutor = async (job) => {
};
};
/** Same interface, same latest-wins rule, on the main thread (nothing can interrupt it, so no watchdog). */
export function createMainThreadClient(execute: MainExecutor = runOnMainThread): RenderClient {
/** Assembles one image-only PDF on this thread. */
export type MainAssembleExecutor = (job: AssembleJob) => Promise<AssembleResult>;
const assembleOnMainThread: MainAssembleExecutor = async (job) => (await import("./assembleJob")).runAssembleJob(job);
/**
* Same interface and lanes as the worker client (latest-wins renders, never-superseded assembles),
* on the main thread. Nothing can interrupt it, so there is no watchdog.
*/
export function createMainThreadClient(
execute: MainExecutor = runOnMainThread,
executeAssemble: MainAssembleExecutor = assembleOnMainThread,
): RenderClient {
let disposed = false;
let running: (Slot & { superseded: boolean }) | null = null;
let pending: Slot | null = null;
let pending: RenderSlot | null = null;
const assembles: AssembleSlot[] = [];
const pump = async () => {
if (running || !pending || disposed) return;
const slot = { ...pending, superseded: false };
pending = null;
if (running || disposed) return;
let next: Slot | null = assembles.shift() ?? null;
if (!next) {
next = pending;
pending = null;
}
if (!next) return;
const slot = { ...next, superseded: false };
running = slot;
try {
const result = await execute(slot.job);
if (!slot.superseded) slot.resolve(result);
if (slot.kind === "render") {
const result = await execute(slot.job);
if (!slot.superseded) slot.resolve(result);
} else {
slot.resolve(await executeAssemble(slot.job));
}
} catch (e) {
if (!slot.superseded) slot.reject(e);
}
@@ -239,11 +303,18 @@ export function createMainThreadClient(execute: MainExecutor = runOnMainThread):
if (disposed) return Promise.reject(new Error("The render client was disposed"));
return new Promise<RenderResult>((resolve, reject) => {
pending?.reject(new Superseded());
if (running && !running.superseded) {
if (running && running.kind === "render" && !running.superseded) {
running.superseded = true;
running.reject(new Superseded());
}
pending = { job, resolve, reject };
pending = { kind: "render", job, resolve, reject };
void pump();
});
},
assemble(job) {
if (disposed) return Promise.reject(new Error("The render client was disposed"));
return new Promise<AssembleResult>((resolve, reject) => {
assembles.push({ kind: "assemble", job, resolve, reject });
void pump();
});
},
@@ -252,6 +323,7 @@ export function createMainThreadClient(execute: MainExecutor = runOnMainThread):
const err = new Error("The render client was disposed");
pending?.reject(err);
pending = null;
for (const a of assembles.splice(0)) a.reject(err);
},
};
}
@@ -261,10 +333,15 @@ export function createMainThreadClient(execute: MainExecutor = runOnMainThread):
* which one is active (main thread: the preview is paused while typing).
*/
export function createAutoClient(
opts: RenderClientOptions & { mainExecutor?: MainExecutor } = {},
opts: RenderClientOptions & { mainExecutor?: MainExecutor; mainAssembleExecutor?: MainAssembleExecutor } = {},
): RenderClient {
const primary = createRenderClient(opts);
let main: RenderClient | null = null;
const fallback = () => {
main ??= createMainThreadClient(opts.mainExecutor, opts.mainAssembleExecutor);
primary.dispose();
return main;
};
return {
get mode() {
return main ? "main" : "worker";
@@ -273,9 +350,14 @@ export function createAutoClient(
if (main) return main.render(job);
return primary.render(job).catch((err) => {
if (!(err instanceof WorkerUnavailable)) throw err;
main ??= createMainThreadClient(opts.mainExecutor);
primary.dispose();
return main.render(job);
return fallback().render(job);
});
},
assemble(job) {
if (main) return main.assemble(job);
return primary.assemble(job).catch((err) => {
if (!(err instanceof WorkerUnavailable)) throw err;
return fallback().assemble(job);
});
},
dispose() {