diff --git a/apps/dev/src/integration/translator/access-control-queue.int.test.ts b/apps/dev/src/integration/translator/access-control-queue.int.test.ts index 8e267ee8f..0072d3f9d 100644 --- a/apps/dev/src/integration/translator/access-control-queue.int.test.ts +++ b/apps/dev/src/integration/translator/access-control-queue.int.test.ts @@ -27,15 +27,13 @@ const withFieldRule = (collections: CollectionConfig[]): CollectionConfig[] => } as CollectionConfig; }); -const jobsFor = async (documentId: string) => { +const runsFor = async (documentId: string) => { const { docs } = await ctx.payload.find({ - collection: "payload-jobs" as "pages", + collection: "translator-runs" as "pages", pagination: false, - where: { workflowSlug: { equals: "translate_document_locales" } } as never, + where: { collectionId: { equals: documentId } } as never, }); - return (docs as Array<{ input?: Record }>).filter( - (j) => j.input?.collection_id === documentId - ); + return docs as Array<{ requesterId?: string | null; requesterCollection?: string | null }>; }; type Assignment = { handle: string }; @@ -100,14 +98,14 @@ describe("a queued translation is decided by whoever asked for it", () => { await ctx?.cleanup(); }); - it("records the requester on the job row", async () => { + it("records the requester on the run, which is what survives until it runs", async () => { const id = await seed("Queued", "Queued line"); await enqueueAs(id, editor); - const [job] = await jobsFor(id); - expect(String(job?.input?.requester_id)).toBe(editor.id); - expect(job?.input?.requester_collection).toBe("users"); + const [run] = await runsFor(id); + expect(run?.requesterId).toBe(editor.id); + expect(run?.requesterCollection).toBe("users"); }); // The request that queued this is long gone by the time it runs, so rebuilding from the row is the diff --git a/apps/dev/src/integration/translator/bootTestPayload.ts b/apps/dev/src/integration/translator/bootTestPayload.ts index c780c4586..c9bfec2e0 100644 --- a/apps/dev/src/integration/translator/bootTestPayload.ts +++ b/apps/dev/src/integration/translator/bootTestPayload.ts @@ -70,11 +70,14 @@ export type TestPayload = { * publish; the enqueue route still works. * @param opts.collections - replaces the shared fixture set entirely (not merged). The set must * still contain a `docs` collection when `autoTranslate` is passed. + * @param opts.unmanagedCollections - registered with Payload, but not handed to the plugin. * @param opts.failFor - target locales the fake provider should throw for, so a spec can exercise a * partial failure. Every other locale translates normally. * @param opts.runner - defaults to the sync runner. `createPayloadJobsRunner({ autoRun: false })` * leaves queued jobs unprocessed in `payload-jobs`, so a spec can read the rows. * @param opts.onTranslate - awaited before each provider call, so a spec can hold a locale mid-run. + * @param opts.deleteJobOnComplete - Payload's own option; Payload defaults it to `true`, this boot + * to `false` so the status panels have rows to read. * @param opts.exclusiveQueue - Payload's `enableConcurrencyControl` for this boot; defaults to * `EXCLUSIVE_QUEUE=1`. * @param opts.fallback - localization fallback, off by default: an unwritten locale reads as @@ -83,7 +86,9 @@ export type TestPayload = { export async function bootTestPayload(opts?: { autoTranslate?: { targets: string[] }; collections?: CollectionConfig[]; + unmanagedCollections?: CollectionConfig[]; fallback?: boolean; + deleteJobOnComplete?: boolean; exclusiveQueue?: boolean; failFor?: string[]; onTranslate?: (targetLng: string) => Promise | void; @@ -146,10 +151,10 @@ export async function bootTestPayload(opts?: { { code: "es", label: "Español" }, ], }, - collections, + collections: [...collections, ...(opts?.unmanagedCollections ?? [])], jobs: { // Payload deletes completed jobs by default, leaving the status panels nothing to read. - deleteJobOnComplete: false, + deleteJobOnComplete: opts?.deleteJobOnComplete ?? false, enableConcurrencyControl: opts?.exclusiveQueue ?? process.env.EXCLUSIVE_QUEUE === "1", }, plugins: [ diff --git a/apps/dev/src/integration/translator/cancel-announcement.int.test.ts b/apps/dev/src/integration/translator/cancel-announcement.int.test.ts index 4deddde8a..d5b0408ce 100644 --- a/apps/dev/src/integration/translator/cancel-announcement.int.test.ts +++ b/apps/dev/src/integration/translator/cancel-announcement.int.test.ts @@ -17,7 +17,7 @@ const runStillOnRecordWhenAnnounced: boolean[] = []; const onCancelled = vi.fn(async (task: Seen) => { const row = await ctx.payload.find({ collection: "payload-jobs" as "docs", - where: { id: { equals: task.handle } } as never, + where: { "input.handle": { equals: task.handle } } as never, }); runStillOnRecordWhenAnnounced.push(row.docs.length === 1); }); diff --git a/apps/dev/src/integration/translator/enqueue-handles.int.test.ts b/apps/dev/src/integration/translator/enqueue-handles.int.test.ts index 61491cd1e..6a5a1ac18 100644 --- a/apps/dev/src/integration/translator/enqueue-handles.int.test.ts +++ b/apps/dev/src/integration/translator/enqueue-handles.int.test.ts @@ -54,9 +54,9 @@ describe("the enqueue answer names the run that will translate each locale", () const rows = await ctx.payload.find({ collection: "payload-jobs" as "docs", pagination: false, - where: { id: { in: assignments.map((j) => j.handle) } } as never, + where: { "input.handle": { in: assignments.map((j) => j.handle) } } as never, }); - expect(rows.docs, "every handle answered names a row that is actually there").toHaveLength( + expect(rows.docs, "every handle answered names a job that is actually there").toHaveLength( new Set(assignments.map((j) => j.handle)).size ); }); @@ -85,19 +85,16 @@ describe("the enqueue answer names the run that will translate each locale", () ).toHaveLength(1); }); - it("still names the run for a locale a live run already covers", async () => { + it("names no run for a document it is already translating", async () => { const id = await create("Asked twice"); const first = await enqueue([id], ["de"]); const second = await enqueue([id], ["de", "fr"]); + expect(first.map((j) => j.target_lng)).toEqual(["de"]); expect( - second.map((j) => j.target_lng).sort(), - "de needed no write, but it is being translated — leaving it out would report nothing for it" - ).toEqual(["de", "fr"]); - expect( - second.find((j) => j.target_lng === "de")?.handle, - "and it is the run that already had it" - ).toBe(first[0]?.handle); + second, + "a second run over this document would put the first run's locales back as they were" + ).toEqual([]); }); }); diff --git a/apps/dev/src/integration/translator/exclusive-queue.int.test.ts b/apps/dev/src/integration/translator/exclusive-queue.int.test.ts index b1a2ee9f6..a7f5ffdde 100644 --- a/apps/dev/src/integration/translator/exclusive-queue.int.test.ts +++ b/apps/dev/src/integration/translator/exclusive-queue.int.test.ts @@ -89,39 +89,6 @@ const titleIn = async (id: string, locale: string) => ).title; describe("with the host's concurrency control on", () => { - it("holds a second job for the same document, then runs it and loses nothing", async () => { - armBarrier(); - const id = await createDoc("Exclusive source"); - - await enqueue(id, ["de"]); - const first = runQueue(); - await held; - - await enqueue(id, ["fr"]); - - // The picker check below also passes when there was nothing to take, so first prove the second - // job exists. - const queued = await jobsFor(id); - expect(queued.length, "the request did not get a job of its own").toBe(2); - expect( - queued.find((job) => !job.processing)?.input?.targets?.map((t) => t.lng), - "the second job should carry only the locale that was asked for" - ).toEqual(["fr"]); - - const whileRunning = await runQueue(); - expect( - Object.keys(whileRunning.jobStatus ?? {}), - "the picker took a second job for a document already being written" - ).toEqual([]); - - release?.(); - await first; - await runQueue(); - - expect(await titleIn(id, "de"), "de was lost").toBe("de:Exclusive source"); - expect(await titleIn(id, "fr"), "fr was lost").toBe("fr:Exclusive source"); - }); - it("still runs jobs for different documents together", async () => { const first = await createDoc("Doc one"); const second = await createDoc("Doc two"); diff --git a/apps/dev/src/integration/translator/field-selection.int.test.ts b/apps/dev/src/integration/translator/field-selection.int.test.ts index 364ec745d..fff4634b2 100644 --- a/apps/dev/src/integration/translator/field-selection.int.test.ts +++ b/apps/dev/src/integration/translator/field-selection.int.test.ts @@ -80,10 +80,12 @@ describe("a run carries the fields it was asked for, through the stored job row" expect(de.note).toBe(tr("de", "This too")); }); - it("keeps one locale's leaf list out of a second request for another locale", async () => { + it("keeps one locale's leaf list out of a later request for another locale", async () => { const id = await create({ title: "Shared run", note: "Only in French" }); + // The first run must settle before the second is accepted: one run at a time per document. await enqueue({ id, target_lng: "de", filters: leaves("title") }); + await work(); await enqueue({ id, target_lng: "fr" }); await work(); diff --git a/apps/dev/src/integration/translator/job-extend.int.test.ts b/apps/dev/src/integration/translator/job-extend.int.test.ts deleted file mode 100644 index 5f6ffa6dc..000000000 --- a/apps/dev/src/integration/translator/job-extend.int.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; - -import { bootTestPayload, CRON_BATCH_LIMIT } from "./bootTestPayload"; -import type { TestPayload } from "./bootTestPayload"; -import { callEndpoint } from "./callEndpoint"; - -type Job = { - id: string | number; - completedAt?: string | null; - processing?: boolean; - input?: { collection_id?: string; targets?: { lng: string }[] }; -}; - -let ctx: TestPayload; - -const enqueue = async (id: string, targets: string[] = ["de"]) => { - const res = await callEndpoint(ctx.payload, "post", "/translate/enqueue", { - body: { - source_lng: "en", - target_lng: targets, - collection_slug: "docs", - collection_id: [id], - - publish_on_translation: false, - }, - }); - expect(res.status, "the enqueue endpoint rejected the request").toBe(200); - const body = res.data as { data: { queued: number } }; - return body.data.queued; -}; - -const jobs = async (documentId: string): Promise => { - const { docs } = await ctx.payload.find({ - collection: "payload-jobs" as "pages", - pagination: false, - where: { workflowSlug: { equals: "translate_document_locales" } } as never, - }); - return (docs as Job[]).filter((j) => j.input?.collection_id === documentId); -}; - -const markFinished = (jobId: string | number) => - ctx.payload.update({ - collection: "payload-jobs" as "pages", - id: jobId, - data: { completedAt: new Date().toISOString(), processing: false } as never, - }); - -const createDoc = async () => { - const doc = await ctx.payload.create({ - collection: "docs" as "pages", - locale: "en", - data: { title: "Src", _status: "published" } as never, - }); - return String(doc.id); -}; - -beforeAll(async () => { - ctx = await bootTestPayload({ runner: createPayloadJobsRunner({ autoRun: false }) }); -}); -afterAll(async () => { - await ctx?.cleanup(); -}); - -describe("a second request extends the live job rather than replacing it", () => { - it("adds its locales to a job that has not started", async () => { - const id = await createDoc(); - await enqueue(id, ["de", "fr"]); - await enqueue(id, ["es"]); - - const live = await jobs(id); - expect(live.length, "the second request queued a job of its own").toBe(1); - expect( - live[0].input?.targets?.map((t) => t.lng), - "the locales already owed were dropped" - ).toEqual(["de", "fr", "es"]); - }); - - it("translates every locale the extended job accumulated", async () => { - const id = await createDoc(); - await enqueue(id, ["de", "fr"]); - await enqueue(id, ["es"]); - - await ctx.payload.jobs.run({ queue: "translations", limit: CRON_BATCH_LIMIT }); - - for (const locale of ["de", "fr", "es"]) { - const doc = (await ctx.payload.findByID({ - collection: "docs" as "pages", - id, - locale: locale as "en", - fallbackLocale: false, - draft: true, - })) as Record; - expect(doc.title, `${locale} was not translated`).toBe(`${locale}:Src`); - } - }); - - it("keeps a locale the live job already owes from being listed twice", async () => { - const id = await createDoc(); - await enqueue(id, ["de"]); - await enqueue(id, ["de"]); - - const live = await jobs(id); - expect(live.length).toBe(1); - expect( - live[0].input?.targets?.map((t) => t.lng), - "the locale was queued a second time" - ).toEqual(["de"]); - }); - - it("leaves a finished job alone and gives the new request its own", async () => { - const id = await createDoc(); - await enqueue(id, ["de"]); - const [first] = await jobs(id); - await markFinished(first.id); - - await enqueue(id, ["fr"]); - - const after = await jobs(id); - expect(after.filter((j) => j.completedAt).length, "the finished job was touched").toBe(1); - expect(after.length, "finished job plus the new one").toBe(2); - }); -}); diff --git a/apps/dev/src/integration/translator/legacy-job-adoption.int.test.ts b/apps/dev/src/integration/translator/legacy-job-adoption.int.test.ts new file mode 100644 index 000000000..480007343 --- /dev/null +++ b/apps/dev/src/integration/translator/legacy-job-adoption.int.test.ts @@ -0,0 +1,156 @@ +import { createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { bootTestPayload, CRON_BATCH_LIMIT } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; +import { callEndpoint } from "./callEndpoint"; + +let ctx: TestPayload; + +const JOBS = "payload-jobs" as "docs"; +const RUNS = "translator-runs" as "docs"; +const WORKFLOW = "translate_document_locales"; + +type Row = { handle: string; targetLng: string; outcome: string | null; collectionId: string }; + +const create = async (title: string): Promise => { + const made = await ctx.payload.create({ + collection: "docs", + locale: "en", + data: { _status: "published", title, note: "A note" }, + }); + return String(made.id); +}; + +/** A row in the shape the plugin wrote before 1.0: the whole run, and no handle. */ +const seedPreUpgradeJob = async ( + documentId: string, + input: Record +): Promise => { + const row = await ctx.payload.create({ + collection: JOBS, + data: { + workflowSlug: WORKFLOW, + queue: "translations", + input: { + collection_slug: "docs", + collection_id: documentId, + source_lng: "en", + ...input, + }, + }, + } as never); + return String((row as { id: unknown }).id); +}; + +const rowsFor = async (documentId: string): Promise => { + const found = await ctx.payload.find({ + collection: RUNS, + where: { collectionId: { equals: documentId } }, + pagination: false, + sort: "targetLng", + }); + return found.docs as unknown as Row[]; +}; + +const jobInput = async (id: string): Promise> => { + const job = (await ctx.payload.findByID({ collection: JOBS, id })) as { input?: unknown }; + return (job.input ?? {}) as Record; +}; + +const runTheQueue = () => ctx.payload.jobs.run({ queue: "translations", limit: CRON_BATCH_LIMIT }); + +/** What the plugin does at boot, driven by hand so a row can be seeded first. */ +const bootAgain = async () => { + const onInit = ctx.payload.config.onInit; + if (onInit) await onInit(ctx.payload); +}; + +describe("a job queued before the upgrade", () => { + beforeAll(async () => { + ctx = await bootTestPayload({ runner: createPayloadJobsRunner({ autoRun: false }) }); + }); + afterAll(async () => { + await ctx?.cleanup(); + }); + + it("is adopted when the queue reaches it, with no boot in between", async () => { + const id = await create("Queued before the upgrade"); + await seedPreUpgradeJob(id, { targets: [{ lng: "de", filters: [] }] }); + + await runTheQueue(); + + const rows = await rowsFor(id); + expect(rows.map((row) => row.targetLng)).toEqual(["de"]); + expect(rows[0].outcome, "and it ran").toBe("delivered"); + }); + + it("is adopted at boot, independently of anything running it", async () => { + const id = await create("Adopted at boot"); + await seedPreUpgradeJob(id, { targets: [{ lng: "fr", filters: [] }] }); + + await bootAgain(); + + const rows = await rowsFor(id); + expect(rows.map((row) => row.targetLng)).toEqual(["fr"]); + expect(rows[0].outcome, "adoption records the run; it does not run it").toBeNull(); + }); + + it("carries its pointer afterwards, so nothing reads the old shape twice", async () => { + const id = await create("Pointer afterwards"); + const jobId = await seedPreUpgradeJob(id, { targets: [{ lng: "de", filters: [] }] }); + + await bootAgain(); + + expect(await jobInput(jobId)).toMatchObject({ handle: jobId, locales: ["de"] }); + }); + + it("leaves one row per locale when it is adopted twice", async () => { + const id = await create("Adopted twice"); + await seedPreUpgradeJob(id, { targets: [{ lng: "de", filters: [] }] }); + + await bootAgain(); + await bootAgain(); + + expect(await rowsFor(id)).toHaveLength(1); + }); + + it("answers the document panel once it has been adopted", async () => { + const id = await create("Answers the panel"); + await seedPreUpgradeJob(id, { targets: [{ lng: "de", filters: [] }] }); + await bootAgain(); + + const res = await callEndpoint( + ctx.payload, + "get", + "/translate/document/:collection_slug/:collection_id", + { routeParams: { collection_slug: "docs", collection_id: id } } + ); + + const rows = (res.data as { data: Array<{ status: string }> }).data; + expect(rows.map((row) => row.status)).toEqual(["pending"]); + }); + + it("is cancellable by the handle it has always had — its own id", async () => { + const id = await create("Cancelled after adoption"); + const jobId = await seedPreUpgradeJob(id, { targets: [{ lng: "de", filters: [] }] }); + await bootAgain(); + + const response = await callEndpoint(ctx.payload, "delete", "/translate/cancel", { + body: { ids: [jobId] }, + }); + + expect(response.status).toBe(204); + const [row] = await rowsFor(id); + expect(row.outcome).toBe("cancelled"); + }); + + it("keeps the locale list a pre-1.0 row named in `target_lngs`", async () => { + const id = await create("The older locale list"); + await seedPreUpgradeJob(id, { target_lngs: ["de", "fr"], strategy: "overwrite" }); + + await bootAgain(); + + expect((await rowsFor(id)).map((row) => row.targetLng)).toEqual(["de", "fr"]); + }); +}); diff --git a/apps/dev/src/integration/translator/legacy-row-upgrade.int.test.ts b/apps/dev/src/integration/translator/legacy-row-upgrade.int.test.ts deleted file mode 100644 index 494b1ec9d..000000000 --- a/apps/dev/src/integration/translator/legacy-row-upgrade.int.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; - -import { bootTestPayload } from "./bootTestPayload"; -import type { TestPayload } from "./bootTestPayload"; -import { callEndpoint } from "./callEndpoint"; - -/* - * The upgrade path, end to end: a row written before 1.0 names `target_lngs` and a `strategy`, and - * carries no `targets`. Appending a locale to such a row writes `targets` beside the fields that - * were already there, so the row then carries both shapes — a state only this path produces. - */ - -type LegacyInput = { - collection_slug?: string; - collection_id?: string; - source_lng?: string; - target_lngs?: string[]; - strategy?: string; - targets?: { lng: string; filters?: unknown[] }[]; -}; -type Job = { id: string | number; input?: LegacyInput }; - -let ctx: TestPayload; - -const createDoc = async () => { - const doc = await ctx.payload.create({ - collection: "docs" as "pages", - locale: "en", - data: { title: "Src", _status: "published" } as never, - }); - return String(doc.id); -}; - -const enqueue = (id: string, locales: string[]) => - callEndpoint(ctx.payload, "post", "/translate/enqueue", { - body: { - source_lng: "en", - target_lng: locales, - collection_slug: "docs", - collection_id: [id], - publish_on_translation: false, - }, - }); - -const jobFor = async (documentId: string): Promise => { - const { docs } = await ctx.payload.find({ - collection: "payload-jobs" as "pages", - pagination: false, - where: { workflowSlug: { equals: "translate_document_locales" } } as never, - }); - const found = (docs as Job[]).find((job) => job.input?.collection_id === documentId); - if (!found) throw new Error("no job row for that document"); - return found; -}; - -/** Rewrites a live row into the shape a pre-1.0 release wrote. */ -const ageTheRow = async (job: Job) => { - const { targets: _dropped, ...rest } = job.input ?? {}; - await ctx.payload.update({ - collection: "payload-jobs" as "pages", - id: job.id, - data: { - input: { ...rest, target_lngs: ["de"], strategy: "skip_existing" }, - } as never, - }); -}; - -beforeAll(async () => { - ctx = await bootTestPayload({ runner: createPayloadJobsRunner({ autoRun: false }) }); -}); -afterAll(async () => { - await ctx?.cleanup(); -}); - -describe("a run row written before 1.0", () => { - it("takes an appended locale without losing the one it already named", async () => { - const id = await createDoc(); - expect((await enqueue(id, ["de"])).status).toBe(200); - await ageTheRow(await jobFor(id)); - - expect((await enqueue(id, ["fr"])).status).toBe(200); - - const input = (await jobFor(id)).input ?? {}; - expect(input.targets?.map((target) => target.lng)).toEqual(["de", "fr"]); - }); - - it("carries its strategy over as the drift rule, and narrows the appended locale by nothing", async () => { - const id = await createDoc(); - expect((await enqueue(id, ["de"])).status).toBe(200); - await ageTheRow(await jobFor(id)); - expect((await enqueue(id, ["fr"])).status).toBe(200); - - const targets = (await jobFor(id)).input?.targets ?? []; - - expect(targets[0]).toMatchObject({ lng: "de", filters: [{ kind: "rule", name: "stale" }] }); - expect(targets[1]).toMatchObject({ lng: "fr", filters: [] }); - }); - - it("still answers the document-status route for the locales it names", async () => { - const id = await createDoc(); - expect((await enqueue(id, ["de"])).status).toBe(200); - await ageTheRow(await jobFor(id)); - - const res = await callEndpoint( - ctx.payload, - "get", - "/translate/document/:collection_slug/:collection_id", - { - routeParams: { collection_slug: "docs", collection_id: id }, - } - ); - - expect(res.status).toBe(200); - const rows = (res.data as { data: { input: { target_lng: string } }[] }).data; - expect(rows.map((row) => row.input.target_lng)).toEqual(["de"]); - }); -}); diff --git a/apps/dev/src/integration/translator/locale-append.int.test.ts b/apps/dev/src/integration/translator/locale-append.int.test.ts deleted file mode 100644 index c4d64d1ee..000000000 --- a/apps/dev/src/integration/translator/locale-append.int.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; - -import { bootTestPayload, CRON_BATCH_LIMIT } from "./bootTestPayload"; -import type { TestPayload } from "./bootTestPayload"; -import { callEndpoint } from "./callEndpoint"; - -// Guards an undocumented Payload behaviour: after each task settles it re-reads the job row onto the -// live `job` object, which is how a locale appended mid-run reaches the handler. Measured on 3.84.1; -// peer floor is ^3.76.0. - -type Job = { - id: string | number; - completedAt?: string | null; - input?: { targets?: { lng: string }[] }; - log?: Array<{ state: string; input?: { target_lng?: string } }>; -}; - -const tr = (locale: string, value: string) => (value.trim() ? `${locale}:${value}` : value); - -let ctx: TestPayload; -let held: (() => void) | undefined; -let heldStarted: (() => void) | undefined; -const heldReached = new Promise((resolve) => { - heldStarted = resolve; -}); - -beforeAll(async () => { - ctx = await bootTestPayload({ - // Pinned off: a running job is only extended without the host's concurrency control — the other - // mode is `exclusive-queue.int.test.ts`. - exclusiveQueue: false, - runner: createPayloadJobsRunner({ autoRun: false }), - onTranslate: async (targetLng) => { - if (targetLng !== "fr") return; - heldStarted?.(); - await new Promise((resolve) => { - held = resolve; - }); - }, - }); -}); -afterAll(async () => { - held?.(); - await ctx?.cleanup(); -}); - -const readJob = async (): Promise => { - const { docs } = await ctx.payload.find({ - collection: "payload-jobs" as "pages", - pagination: false, - where: { workflowSlug: { equals: "translate_document_locales" } } as never, - }); - return docs[0] as Job; -}; - -describe("adding a locale to a running job", () => { - it("reaches the handler, and the log written before it survives", async () => { - const doc = await ctx.payload.create({ - collection: "docs" as "pages", - locale: "en", - data: { title: "Append source", _status: "published" } as never, - }); - const id = String(doc.id); - - await callEndpoint(ctx.payload, "post", "/translate/enqueue", { - body: { - source_lng: "en", - target_lng: ["de", "fr"], - collection_slug: "docs", - collection_id: [id], - - publish_on_translation: false, - }, - }); - - const run = ctx.payload.jobs.run({ queue: "translations", limit: CRON_BATCH_LIMIT }); - await heldReached; - - // The surviving-log claim is vacuous unless the log is non-empty when the second request lands. - const before = await readJob(); - expect( - (before.log ?? []).map((e) => e.input?.target_lng), - "fixture: de should already be logged when the second request lands" - ).toEqual(["de"]); - - await callEndpoint(ctx.payload, "post", "/translate/enqueue", { - body: { - source_lng: "en", - target_lng: ["es"], - collection_slug: "docs", - collection_id: [id], - - publish_on_translation: false, - }, - }); - - held?.(); - await run; - - const after = await readJob(); - const logged = (after.log ?? []).map((e) => [e.input?.target_lng, e.state]); - - const esTitle = ( - (await ctx.payload.findByID({ - collection: "docs" as "pages", - id, - locale: "es" as "en", - fallbackLocale: false, - draft: true, - })) as Record - ).title; - - expect( - logged.map((l) => l[0]), - "the appended locale never ran" - ).toEqual(["de", "fr", "es"]); - expect(esTitle, "the appended locale was not translated").toBe(tr("es", "Append source")); - - const { totalDocs } = await ctx.payload.count({ - collection: "payload-jobs" as "pages", - where: { workflowSlug: { equals: "translate_document_locales" } } as never, - }); - expect(totalDocs, "a second job was queued alongside the running one").toBe(1); - }); -}); diff --git a/apps/dev/src/integration/translator/locale-workflow-failure.int.test.ts b/apps/dev/src/integration/translator/locale-workflow-failure.int.test.ts index f886f4af3..a2df7c44a 100644 --- a/apps/dev/src/integration/translator/locale-workflow-failure.int.test.ts +++ b/apps/dev/src/integration/translator/locale-workflow-failure.int.test.ts @@ -100,11 +100,11 @@ describe("when one locale's provider fails", () => { []; expect( rows.map((row) => [row.input.target_lng, row.status]).sort(), - "a locale is finished only on its own evidence; one whose attempt failed is still owed a retry, so it reads as pending rather than as a failure to act on" + "a locale is finished only on its own evidence: the one whose attempt failed is still owed a retry, so it reads as work in flight rather than as a failure to act on, and the one never reached has not started at all" ).toEqual([ ["de", "completed"], ["es", "pending"], - ["fr", "pending"], + ["fr", "running"], ]); // Payload backs a failed job off exponentially; only the manual-run endpoint lifts that, so the @@ -112,7 +112,7 @@ describe("when one locale's provider fails", () => { const before = failing.translateCount(); const retry = await callEndpoint(failing.payload, "post", "/translate/run/:id", { user: editor as never, - routeParams: { id: String((docs[0] as { id: string | number }).id) }, + routeParams: { id: (docs[0] as { input?: { handle?: string } }).input?.handle }, }); expect(retry.status, "the retry endpoint did not accept the job").toBe(204); diff --git a/apps/dev/src/integration/translator/locale-workflow.int.test.ts b/apps/dev/src/integration/translator/locale-workflow.int.test.ts index bffd36e2f..e0ab0e2e3 100644 --- a/apps/dev/src/integration/translator/locale-workflow.int.test.ts +++ b/apps/dev/src/integration/translator/locale-workflow.int.test.ts @@ -28,6 +28,8 @@ const createDoc = async (title: string) => { return String(doc.id); }; +const handleFor = new Map(); + const enqueue = async (id: string, targets: string[]) => { const res = await callEndpoint(ctx.payload, "post", "/translate/enqueue", { body: { @@ -40,6 +42,9 @@ const enqueue = async (id: string, targets: string[]) => { }, }); expect(res.status, "the enqueue endpoint rejected the request").toBe(200); + const assignments = + (res.data as { data: { assignments?: Array<{ handle: string }> } }).data.assignments ?? []; + if (assignments[0]) handleFor.set(id, assignments[0].handle); }; const runQueue = () => ctx.payload.jobs.run({ queue: "translations", limit: CRON_BATCH_LIMIT }); @@ -64,10 +69,10 @@ const workflowJob = async (id: string) => { return ( docs as Array<{ completedAt?: string | null; - input?: { collection_id?: string }; + input?: { handle?: string }; log?: Array>; }> - ).find((j) => j.input?.collection_id === id); + ).find((j) => j.input?.handle === handleFor.get(id)); }; describe("translating one document into several locales", () => { diff --git a/apps/dev/src/integration/translator/one-run-per-document.int.test.ts b/apps/dev/src/integration/translator/one-run-per-document.int.test.ts new file mode 100644 index 000000000..f0eae48c3 --- /dev/null +++ b/apps/dev/src/integration/translator/one-run-per-document.int.test.ts @@ -0,0 +1,116 @@ +import { createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { bootTestPayload, CRON_BATCH_LIMIT } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; +import { callEndpoint } from "./callEndpoint"; + +// `createPayloadJobsRunner({ autoRun: false })` rather than the suite's default sync runner: only an +// unprocessed queue leaves a run unsettled between requests. + +let ctx: TestPayload; + +type Answer = { + queued: number; + busy?: string[]; + refused?: string[]; + assignments?: Array<{ handle: string; target_lng: string; collection_id: string }>; +}; + +const create = async (title: string): Promise => { + const made = await ctx.payload.create({ + collection: "docs", + locale: "en", + data: { _status: "published", title, note: "A note" }, + }); + return String(made.id); +}; + +const enqueue = async (ids: string[], locales: string[]): Promise => { + const res = await callEndpoint(ctx.payload, "post", "/translate/enqueue", { + body: { + source_lng: "en", + target_lng: locales, + collection_slug: "docs", + collection_id: ids, + publish_on_translation: false, + }, + }); + return (res.data as { data: Answer }).data; +}; + +const german = async (id: string): Promise => { + const doc = (await ctx.payload.findByID({ + collection: "docs", + id, + locale: "de", + fallbackLocale: false, + draft: true, + })) as Record; + return doc.title; +}; + +describe("one run per document at a time", () => { + beforeAll(async () => { + ctx = await bootTestPayload({ runner: createPayloadJobsRunner({ autoRun: false }) }); + }); + afterAll(async () => { + await ctx?.cleanup(); + }); + + it("turns away a second request while the first run is still owed", async () => { + const id = await create("Asked twice in a row"); + + await enqueue([id], ["de"]); + const second = await enqueue([id], ["fr"]); + + expect(second.queued).toBe(0); + expect(second.busy).toEqual([id]); + expect(second.assignments ?? []).toEqual([]); + }); + + it("leaves the first run to finish, and it does", async () => { + const id = await create("The first run wins"); + await enqueue([id], ["de"]); + await enqueue([id], ["fr"]); + + await ctx.payload.jobs.run({ queue: "translations", limit: CRON_BATCH_LIMIT }); + + expect(await german(id), "the run that was accepted translated its locale").toBe( + "de:The first run wins" + ); + }); + + it("takes the document again once its run has settled", async () => { + const id = await create("Free again afterwards"); + await enqueue([id], ["de"]); + await ctx.payload.jobs.run({ queue: "translations", limit: CRON_BATCH_LIMIT }); + + const again = await enqueue([id], ["fr"]); + + expect(again.queued).toBe(1); + expect(again.busy).toBeUndefined(); + }); + + it("queues the free documents of a bulk request and names only the busy one", async () => { + const busy = await create("Busy in a bulk request"); + const free = await create("Free in the same request"); + await enqueue([busy], ["de"]); + + const bulk = await enqueue([busy, free], ["fr"]); + + expect(bulk.busy).toEqual([busy]); + expect(bulk.assignments?.map((a) => a.collection_id)).toEqual([free]); + }); + + it("frees the document again after its run is cancelled", async () => { + const id = await create("Cancelled, then asked again"); + const first = await enqueue([id], ["de"]); + const handle = first.assignments?.[0]?.handle; + + await callEndpoint(ctx.payload, "delete", "/translate/cancel", { body: { ids: [handle] } }); + const again = await enqueue([id], ["de"]); + + expect(again.queued, "cancelling is the way out of a run that will not finish").toBe(1); + }); +}); diff --git a/apps/dev/src/integration/translator/panel-shows-what-is-happening.int.test.ts b/apps/dev/src/integration/translator/panel-shows-what-is-happening.int.test.ts index 369d7ebaa..edc5d82d4 100644 --- a/apps/dev/src/integration/translator/panel-shows-what-is-happening.int.test.ts +++ b/apps/dev/src/integration/translator/panel-shows-what-is-happening.int.test.ts @@ -7,9 +7,7 @@ import { callEndpoint } from "./callEndpoint"; let ctx: TestPayload; -const JOBS = "payload-jobs" as "docs"; -const WORKFLOW = "translate_document_locales"; -const QUEUE = "translations"; +const RUNS = "translator-runs" as "docs"; const WINDOW_DAYS = 3; const WINDOW_MS = WINDOW_DAYS * 24 * 60 * 60 * 1000; @@ -47,54 +45,39 @@ const panel = async (): Promise<{ docs: Entry[]; failed_window_ms: number }> => const daysAgo = (days: number): string => new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); -const ERROR = { name: "Error", message: "boom", cancelled: false, stack: "" }; - -/** - * `gave-up` and `retrying` are the only two failure shapes Payload writes: `handleWorkflowError.js` - * stores `error` on every attempt and sets `hasError` only once it stops retrying. - */ -const failureShape = (failure: "gave-up" | "retrying"): Record => { - if (failure === "gave-up") return { error: ERROR, hasError: true }; - return { error: ERROR }; -}; - -/** `payload.update` rewrites `updatedAt` with now, so a backdated run goes through `payload.db.updateOne`. */ +/** Seeded rather than translated, so a case can place a failure days in the past. */ const seedRun = async ( documentId: string, targetLng: string, - state: { - failure?: "gave-up" | "retrying"; - completedAt?: string; - updatedAt?: string; - log?: Array>; + progress: { + startedAt?: string; + settledAt?: string; + outcome?: "delivered" | "failed" | "cancelled"; + error?: string; } ): Promise => { - const row = await ctx.payload.create({ - collection: JOBS, + await ctx.payload.create({ + collection: RUNS, data: { - workflowSlug: WORKFLOW, - queue: QUEUE, - input: { - collection_slug: "docs", - collection_id: documentId, - source_lng: "en", - targets: [{ lng: targetLng }], - }, - ...(state.failure === undefined ? {} : failureShape(state.failure)), - ...(state.completedAt === undefined ? {} : { completedAt: state.completedAt }), - ...(state.log === undefined ? {} : { log: state.log }), + handle: `seeded-${documentId}-${targetLng}`, + collectionSlug: "docs", + collectionId: documentId, + sourceLng: "en", + targetLng, + publishOnTranslation: false, + queuedAt: progress.startedAt ?? new Date().toISOString(), + ...progress, }, } as never); - - if (state.updatedAt === undefined) return; - await ctx.payload.db.updateOne({ - collection: JOBS, - id: (row as { id: unknown }).id, - data: { updatedAt: state.updatedAt }, - returning: false, - } as never); }; +/** + * A locale the runner has picked up and not answered for. The record is told a locale failed only + * once the run gives up, so between attempts there is a start and no settlement. + */ +const seedRunBetweenAttempts = (id: string, targetLng: string, lastAttemptAt: string) => + seedRun(id, targetLng, { startedAt: lastAttemptAt }); + const localesIn = (entries: Entry[]): string[] => entries.map((entry) => entry.target_lng).sort(); describe("the collection panel reports what is happening", () => { @@ -117,7 +100,11 @@ describe("the collection panel reports what is happening", () => { it("says nothing about a run that completed", async () => { const id = await create("Completed"); - await seedRun(id, "it", { completedAt: new Date().toISOString() }); + await seedRun(id, "it", { + startedAt: daysAgo(0), + settledAt: new Date().toISOString(), + outcome: "delivered", + }); const entries = (await panel()).docs.filter((entry) => entry.collection_id === id); expect( @@ -128,7 +115,12 @@ describe("the collection panel reports what is happening", () => { it("names a run that failed inside the window", async () => { const id = await create("Failed recently"); - await seedRun(id, "es", { failure: "gave-up", updatedAt: daysAgo(1) }); + await seedRun(id, "es", { + startedAt: daysAgo(1), + settledAt: daysAgo(1), + outcome: "failed", + error: "it broke", + }); const entries = (await panel()).docs.filter((entry) => entry.collection_id === id); expect(localesIn(entries), "a recent failure is a call to action").toEqual(["es"]); @@ -140,7 +132,12 @@ describe("the collection panel reports what is happening", () => { it("says nothing about a run that failed before the window", async () => { const id = await create("Failed long ago"); - await seedRun(id, "pt", { failure: "gave-up", updatedAt: daysAgo(WINDOW_DAYS + 1) }); + await seedRun(id, "pt", { + startedAt: daysAgo(WINDOW_DAYS + 1), + settledAt: daysAgo(WINDOW_DAYS + 1), + outcome: "failed", + error: "it broke", + }); const entries = (await panel()).docs.filter((entry) => entry.collection_id === id); expect(entries, "a failure from months ago is not an action").toEqual([]); @@ -148,31 +145,17 @@ describe("the collection panel reports what is happening", () => { it("treats a run that is still retrying as in flight, not as failed", async () => { const id = await create("Retrying"); - await seedRun(id, "nl", { - failure: "retrying", - updatedAt: daysAgo(WINDOW_DAYS + 1), - log: [ - { - taskSlug: "translate_document", - taskID: "nl", - state: "failed", - executedAt: daysAgo(WINDOW_DAYS + 1), - completedAt: daysAgo(WINDOW_DAYS + 1), - error: { message: "boom" }, - input: { target_lng: "nl" }, - }, - ], - }); + await seedRunBetweenAttempts(id, "nl", daysAgo(WINDOW_DAYS + 1)); const entries = (await panel()).docs.filter((entry) => entry.collection_id === id); expect( localesIn(entries), - "hasError is set only on final give-up, so a retrying run is still work in flight" + "a run between attempts is still work in flight, however old its last attempt" ).toEqual(["nl"]); expect( entries.map((entry) => entry.status), "telling someone to open a document over a run that is still retrying is a false alarm" - ).toEqual(["pending"]); + ).toEqual(["running"]); }); it("reports the window it counted failures over", async () => { diff --git a/apps/dev/src/integration/translator/run-record-read.int.test.ts b/apps/dev/src/integration/translator/run-record-read.int.test.ts new file mode 100644 index 000000000..e814749bd --- /dev/null +++ b/apps/dev/src/integration/translator/run-record-read.int.test.ts @@ -0,0 +1,145 @@ +import { createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { bootTestPayload, CRON_BATCH_LIMIT } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; +import { callEndpoint } from "./callEndpoint"; + +// `autoRun: false` rather than the suite's default sync runner, which settles every locale inside +// `enqueue`: only an unprocessed queue leaves work in flight to read. + +let ctx: TestPayload; + +type DocumentEntry = { + id: string; + status: string; + input: { target_lng: string }; +}; + +type CollectionEntry = { id: string; status: string; collection_id: string; target_lng: string }; + +const create = async (title: string): Promise => { + const made = await ctx.payload.create({ + collection: "docs", + locale: "en", + data: { _status: "published", title, note: "A note" }, + }); + return String(made.id); +}; + +const enqueue = (id: string, locales: string[]) => + callEndpoint(ctx.payload, "post", "/translate/enqueue", { + body: { + source_lng: "en", + target_lng: locales, + collection_slug: "docs", + collection_id: [id], + publish_on_translation: false, + }, + }); + +const documentStatus = async (id: string): Promise => { + const res = await callEndpoint( + ctx.payload, + "get", + "/translate/document/:collection_slug/:collection_id", + { + routeParams: { collection_slug: "docs", collection_id: id }, + } + ); + return (res.data as { data: DocumentEntry[] }).data; +}; + +const collectionStatus = async (): Promise => { + const res = await callEndpoint(ctx.payload, "get", "/translate/collection/:collection_slug", { + routeParams: { collection_slug: "docs" }, + }); + return (res.data as { data: { docs: CollectionEntry[] } }).data.docs; +}; + +describe("the panels read the plugin's own record", () => { + beforeAll(async () => { + ctx = await bootTestPayload({ runner: createPayloadJobsRunner({ autoRun: false }) }); + }); + afterAll(async () => { + await ctx?.cleanup(); + }); + + it("shows a queued locale as pending before anything runs it", async () => { + const id = await create("A document waiting in the queue"); + + await enqueue(id, ["de", "fr"]); + + const entries = await documentStatus(id); + expect(entries.map((e) => e.input.target_lng).sort()).toEqual(["de", "fr"]); + expect(entries.every((e) => e.status === "pending")).toBe(true); + }); + + it("shows the same work on the collection panel", async () => { + const id = await create("A document the collection panel must see"); + + await enqueue(id, ["de"]); + + const entries = await collectionStatus(); + expect(entries.some((e) => e.collection_id === id && e.status === "pending")).toBe(true); + }); + + it("shows a locale as completed once the queue has run it", async () => { + const id = await create("A document that will be translated"); + await enqueue(id, ["de"]); + + await ctx.payload.jobs.run({ queue: "translations", limit: CRON_BATCH_LIMIT }); + + const [entry] = await documentStatus(id); + expect(entry.status).toBe("completed"); + }); + + it("drops a finished locale from the collection panel", async () => { + const id = await create("A document whose run is over"); + await enqueue(id, ["de"]); + await ctx.payload.jobs.run({ queue: "translations", limit: CRON_BATCH_LIMIT }); + + const entries = await collectionStatus(); + expect(entries.some((e) => e.collection_id === id)).toBe(false); + }); + + it("stops a queued run named by its handle, and says nothing was refused", async () => { + const id = await create("A document whose run is cancelled by handle"); + const answer = await enqueue(id, ["de"]); + const [assignment] = (answer.data as { data: { assignments: Array<{ handle: string }> } }).data + .assignments; + + const cancelled = await callEndpoint(ctx.payload, "delete", "/translate/cancel", { + body: { ids: [assignment.handle] }, + }); + + expect(cancelled.status).toBe(204); + expect(await documentStatus(id), "a cancelled locale has no word on the panel").toEqual([]); + }); + + it("stops every queued run of a collection at once", async () => { + const first = await create("One of two to cancel together"); + const second = await create("The other of two to cancel together"); + await enqueue(first, ["de"]); + await enqueue(second, ["fr"]); + + const response = await callEndpoint( + ctx.payload, + "delete", + "/translate/cancel-by-collection/:collection_slug", + { routeParams: { collection_slug: "docs" } } + ); + + expect(response.status).toBe(204); + expect(await documentStatus(first)).toEqual([]); + expect(await documentStatus(second)).toEqual([]); + }); + + it("answers 404 for a handle the record does not name", async () => { + const response = await callEndpoint(ctx.payload, "post", "/translate/run/:id", { + routeParams: { id: "999999" }, + }); + + expect(response.status).toBe(404); + }); +}); diff --git a/apps/dev/src/integration/translator/run-record-survives-the-job.int.test.ts b/apps/dev/src/integration/translator/run-record-survives-the-job.int.test.ts new file mode 100644 index 000000000..1312da89f --- /dev/null +++ b/apps/dev/src/integration/translator/run-record-survives-the-job.int.test.ts @@ -0,0 +1,76 @@ +import { createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { bootTestPayload, CRON_BATCH_LIMIT } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; +import { callEndpoint } from "./callEndpoint"; + +// Its own file because `getPayload` caches one instance per process, and this boot needs Payload's +// own `deleteJobOnComplete: true` rather than the suite's `false`. + +const RUNS = "translator-runs"; + +const create = async (ctx: TestPayload, title: string): Promise => { + const made = await ctx.payload.create({ + collection: "docs", + locale: "en", + data: { _status: "published", title, note: "A note" }, + }); + return String(made.id); +}; + +const enqueue = (ctx: TestPayload, id: string, locales: string[]) => + callEndpoint(ctx.payload, "post", "/translate/enqueue", { + body: { + source_lng: "en", + target_lng: locales, + collection_slug: "docs", + collection_id: [id], + publish_on_translation: false, + }, + }); + +const rowsFor = async ( + ctx: TestPayload, + id: string +): Promise> => { + const found = await ctx.payload.find({ + collection: RUNS, + where: { + and: [{ collectionSlug: { equals: "docs" } }, { collectionId: { equals: id } }], + }, + limit: 100, + pagination: false, + }); + return found.docs as unknown as Array<{ handle: string; outcome: string | null }>; +}; + +describe("the run record outlives the job, on Payload's own default", () => { + let ctx: TestPayload; + + beforeAll(async () => { + ctx = await bootTestPayload({ + runner: createPayloadJobsRunner({ autoRun: false }), + deleteJobOnComplete: true, + }); + }); + afterAll(async () => { + await ctx?.cleanup(); + }); + + it("still says delivered after Payload has deleted the job row", async () => { + const id = await create(ctx, "A document whose job will be deleted"); + await enqueue(ctx, id, ["de"]); + + await ctx.payload.jobs.run({ queue: "translations", limit: CRON_BATCH_LIMIT }); + + const [row] = await rowsFor(ctx, id); + const jobs = await ctx.payload.find({ + collection: "payload-jobs" as "docs", + where: { id: { equals: row.handle } } as never, + pagination: false, + }); + expect(jobs.docs, "Payload's default removes the job once it completes").toHaveLength(0); + expect(row.outcome, "the plugin's own row is what survives").toBe("delivered"); + }); +}); diff --git a/apps/dev/src/integration/translator/run-record-write.int.test.ts b/apps/dev/src/integration/translator/run-record-write.int.test.ts new file mode 100644 index 000000000..7f72129c7 --- /dev/null +++ b/apps/dev/src/integration/translator/run-record-write.int.test.ts @@ -0,0 +1,119 @@ +import { createPayloadJobsRunner } from "@focus-reactive/payload-plugin-translator"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { bootTestPayload, CRON_BATCH_LIMIT } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; +import { callEndpoint } from "./callEndpoint"; + +// The unit specs assert what the plugin asks the store to write; these assert that the columns take it. + +const RUNS = "translator-runs"; + +type Row = { + handle: string; + targetLng: string; + queuedAt: string | null; + startedAt: string | null; + settledAt: string | null; + outcome: string | null; + error: string | null; + attempts: number | null; + publishOnTranslation: boolean; + filters: string | null; +}; + +const create = async (ctx: TestPayload, title: string): Promise => { + const made = await ctx.payload.create({ + collection: "docs", + locale: "en", + data: { _status: "published", title, note: "A note" }, + }); + return String(made.id); +}; + +const enqueue = (ctx: TestPayload, id: string, locales: string[]) => + callEndpoint(ctx.payload, "post", "/translate/enqueue", { + body: { + source_lng: "en", + target_lng: locales, + collection_slug: "docs", + collection_id: [id], + publish_on_translation: false, + }, + }); + +const rowsFor = async (ctx: TestPayload, id: string): Promise => { + const found = await ctx.payload.find({ + collection: RUNS, + where: { + and: [{ collectionSlug: { equals: "docs" } }, { collectionId: { equals: id } }], + }, + limit: 100, + pagination: false, + sort: "targetLng", + }); + return found.docs as unknown as Row[]; +}; + +describe("what a translation writes into the run record", () => { + let ctx: TestPayload; + + beforeAll(async () => { + ctx = await bootTestPayload({ failFor: ["es"] }); + }); + afterAll(async () => { + await ctx?.cleanup(); + }); + + it("writes one row per locale and settles each one", async () => { + const id = await create(ctx, "A document to translate"); + + await enqueue(ctx, id, ["de", "fr"]); + + const rows = await rowsFor(ctx, id); + expect(rows.map((row) => row.targetLng)).toEqual(["de", "fr"]); + for (const row of rows) { + expect(row.outcome, `${row.targetLng} settled`).toBe("delivered"); + expect(row.settledAt).not.toBeNull(); + expect(row.error).toBeNull(); + } + }); + + it("stamps when the locale was queued, when it started and what attempt it was on", async () => { + const id = await create(ctx, "A document whose timings matter"); + + await enqueue(ctx, id, ["de"]); + + const [row] = await rowsFor(ctx, id); + expect(row.queuedAt).not.toBeNull(); + expect(row.startedAt, "the plugin's handler ran, so the row says so").not.toBeNull(); + expect(row.attempts).toBe(1); + expect(Date.parse(row.startedAt as string)).toBeGreaterThanOrEqual( + Date.parse(row.queuedAt as string) + ); + }); + + it("records the failure of a locale the provider refused, and only that locale", async () => { + const id = await create(ctx, "A document one locale will refuse"); + + await enqueue(ctx, id, ["de", "es"]); + + const rows = await rowsFor(ctx, id); + const byLocale = new Map(rows.map((row) => [row.targetLng, row])); + expect(byLocale.get("de")?.outcome).toBe("delivered"); + expect(byLocale.get("es")?.outcome).toBe("failed"); + expect(byLocale.get("es")?.error, "a failure says something").toBeTruthy(); + }); + + it("names the run the same way the enqueue answer did", async () => { + const id = await create(ctx, "A document whose handle must match"); + + const answer = await enqueue(ctx, id, ["de"]); + const assignments = + (answer.data as { data: { assignments?: Array<{ handle: string; target_lng: string }> } }) + .data.assignments ?? []; + + const [row] = await rowsFor(ctx, id); + expect(assignments.map((a) => a.handle)).toContain(row.handle); + }); +}); diff --git a/apps/dev/src/integration/translator/run-record.int.test.ts b/apps/dev/src/integration/translator/run-record.int.test.ts new file mode 100644 index 000000000..7e4bf89c2 --- /dev/null +++ b/apps/dev/src/integration/translator/run-record.int.test.ts @@ -0,0 +1,137 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { bootTestPayload } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; + +// Rows are seeded through the Local API, not translated: what a real translation writes is +// `run-record-write.int.test.ts`. + +const RUNS = "translator-runs"; + +/** + * Mongo builds a collection's indexes in the background after its first write, so on a brand-new + * database a duplicate inserted in that window is accepted — measured: only `_id` and `createdAt` + * exist straight after the first insert, the rest about three seconds later. SQL adapters have the + * key from the CREATE TABLE onwards. + */ +const KEY_IS_ENFORCED_AT_ONCE = (process.env.DB_ADAPTER ?? "sqlite") !== "mongo"; + +type SeedRow = { + handle: string; + collectionSlug: string; + collectionId: string; + targetLng?: string; +}; + +const seed = (ctx: TestPayload, row: SeedRow) => + ctx.payload.create({ + collection: RUNS, + data: { + sourceLng: "en", + targetLng: "de", + publishOnTranslation: false, + queuedAt: new Date().toISOString(), + ...row, + }, + }); + +/** Both halves of the key: document ids are per-collection, so an id alone names rows of others. */ +const rowsFor = async ( + ctx: TestPayload, + collectionSlug: string, + collectionId: string +): Promise => { + const found = await ctx.payload.find({ + collection: RUNS, + where: { + and: [ + { collectionSlug: { equals: collectionSlug } }, + { collectionId: { equals: collectionId } }, + ], + }, + limit: 100, + pagination: false, + }); + return found.docs; +}; + +const createDoc = async (ctx: TestPayload, collection: string, title: string): Promise => { + const created = await ctx.payload.create({ + collection, + locale: "en", + data: { _status: "published", title }, + }); + return String(created.id); +}; + +describe("the run record collection", () => { + let ctx: TestPayload; + + beforeAll(async () => { + ctx = await bootTestPayload({ + unmanagedCollections: [{ slug: "assets", fields: [{ name: "title", type: "text" }] }], + }); + }); + afterAll(async () => { + await ctx?.cleanup(); + }); + + it("is registered, and hidden from the admin", () => { + const collection = ctx.payload.config.collections.find((c) => c.slug === RUNS); + + expect(collection).toBeDefined(); + expect(collection?.admin?.hidden).toBe(true); + }); + + it.skipIf(!KEY_IS_ENFORCED_AT_ONCE)( + "holds one row per handle and target locale, and refuses a second", + async () => { + const id = await createDoc(ctx, "docs", "one key"); + await seed(ctx, { handle: "h-unique", collectionSlug: "docs", collectionId: id }); + + await expect( + seed(ctx, { handle: "h-unique", collectionSlug: "docs", collectionId: id }) + ).rejects.toThrow(); + + expect(await rowsFor(ctx, "docs", id)).toHaveLength(1); + } + ); + + it("holds a row per locale under one handle", async () => { + const id = await createDoc(ctx, "docs", "two locales"); + await seed(ctx, { handle: "h-fan", collectionSlug: "docs", collectionId: id, targetLng: "de" }); + await seed(ctx, { handle: "h-fan", collectionSlug: "docs", collectionId: id, targetLng: "fr" }); + + expect(await rowsFor(ctx, "docs", id)).toHaveLength(2); + }); + + it("loses a document's rows when the document is deleted", async () => { + const id = await createDoc(ctx, "docs", "to be deleted"); + await seed(ctx, { handle: "h-del", collectionSlug: "docs", collectionId: id, targetLng: "de" }); + await seed(ctx, { handle: "h-del", collectionSlug: "docs", collectionId: id, targetLng: "fr" }); + + await ctx.payload.delete({ collection: "docs", id }); + + expect(await rowsFor(ctx, "docs", id)).toHaveLength(0); + }); + + it("keeps the rows of every other document", async () => { + const kept = await createDoc(ctx, "docs", "a bystander"); + const doomed = await createDoc(ctx, "docs", "the deleted one"); + await seed(ctx, { handle: "h-keep", collectionSlug: "docs", collectionId: kept }); + await seed(ctx, { handle: "h-doom", collectionSlug: "docs", collectionId: doomed }); + + await ctx.payload.delete({ collection: "docs", id: doomed }); + + expect(await rowsFor(ctx, "docs", kept)).toHaveLength(1); + }); + + it("leaves rows alone when a collection the plugin does not translate is deleted", async () => { + const asset = await createDoc(ctx, "assets", "an untranslated asset"); + await seed(ctx, { handle: "h-asset", collectionSlug: "assets", collectionId: asset }); + + await ctx.payload.delete({ collection: "assets", id: asset }); + + expect(await rowsFor(ctx, "assets", asset)).toHaveLength(1); + }); +}); diff --git a/apps/dev/src/integration/translator/runner-capabilities.int.test.ts b/apps/dev/src/integration/translator/runner-capabilities.int.test.ts new file mode 100644 index 000000000..74c035356 --- /dev/null +++ b/apps/dev/src/integration/translator/runner-capabilities.int.test.ts @@ -0,0 +1,65 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { bootTestPayload } from "./bootTestPayload"; +import type { TestPayload } from "./bootTestPayload"; +import { callEndpoint } from "./callEndpoint"; + +let ctx: TestPayload; + +const create = async (title: string): Promise => { + const made = await ctx.payload.create({ + collection: "docs", + locale: "en", + data: { _status: "published", title, note: "A note" }, + }); + return String(made.id); +}; + +const paths = (): string[] => (ctx.payload.config.endpoints ?? []).map((e) => e.path); + +describe("a runner that only accepts work", () => { + beforeAll(async () => { + ctx = await bootTestPayload(); + }); + afterAll(async () => { + await ctx?.cleanup(); + }); + + it("registers no route it cannot serve", () => { + expect(paths()).not.toContain("/translate/cancel"); + expect(paths()).not.toContain("/translate/cancel-by-collection/:collection_slug"); + expect(paths()).not.toContain("/translate/run/:id"); + }); + + it("still registers the routes that rest on the plugin's own record", () => { + expect(paths()).toContain("/translate/enqueue"); + expect(paths()).toContain("/translate/document/:collection_slug/:collection_id"); + expect(paths()).toContain("/translate/collection/:collection_slug"); + }); + + it("translates, and the document panel says the locale is done", async () => { + const id = await create("Translated inline"); + + await callEndpoint(ctx.payload, "post", "/translate/enqueue", { + body: { + source_lng: "en", + target_lng: ["de"], + collection_slug: "docs", + collection_id: [id], + publish_on_translation: false, + }, + }); + + const status = await callEndpoint( + ctx.payload, + "get", + "/translate/document/:collection_slug/:collection_id", + { routeParams: { collection_slug: "docs", collection_id: id } } + ); + const rows = (status.data as { data: Array<{ status: string }> }).data; + expect( + rows.map((row) => row.status), + "the record outlives the request, so status does not depend on the runner keeping anything" + ).toEqual(["completed"]); + }); +}); diff --git a/apps/dev/src/integration/translator/status-reads-are-bounded.int.test.ts b/apps/dev/src/integration/translator/status-reads-are-bounded.int.test.ts index 98909a92f..ddab36c81 100644 --- a/apps/dev/src/integration/translator/status-reads-are-bounded.int.test.ts +++ b/apps/dev/src/integration/translator/status-reads-are-bounded.int.test.ts @@ -8,27 +8,25 @@ import { callEndpoint } from "./callEndpoint"; let ctx: TestPayload; const JOBS = "payload-jobs" as "docs"; +const RUNS = "translator-runs" as "docs"; const WORKFLOW = "translate_document_locales"; const QUEUE = "translations"; -type JobRow = { id: unknown; input?: Record }; +type RunRow = { id: unknown; collectionSlug?: unknown; collectionId?: unknown }; /** - * Every job row the plugin pulled out of the database while serving one request. - * - * The endpoints' answers cannot show this: today they narrow after the read, so a correct answer is - * exactly what an unbounded read produces. What the work changes is how much came back, which is - * only visible from the read itself. + * The endpoints narrow after the read, so their answer is identical whether or not the read was + * bounded; only the read itself shows how much came back. */ -let loaded: JobRow[][] = []; +let runRowsPerRead: RunRow[][] = []; let restore: (() => void) | undefined; -const watchJobReads = () => { +const watchRunReads = () => { const real = ctx.payload.find.bind(ctx.payload); - loaded = []; + runRowsPerRead = []; ctx.payload.find = (async (args: { collection: string }) => { const result = await real(args as Parameters[0]); - if (args.collection === JOBS) loaded.push(result.docs as unknown as JobRow[]); + if (args.collection === RUNS) runRowsPerRead.push(result.docs as unknown as RunRow[]); return result; }) as typeof ctx.payload.find; restore = () => { @@ -36,7 +34,7 @@ const watchJobReads = () => { }; }; -const rowsRead = (): JobRow[] => loaded.flat(); +const rowsRead = (): RunRow[] => runRowsPerRead.flat(); const create = async (title: string): Promise => { const made = await ctx.payload.create({ @@ -60,62 +58,24 @@ const enqueue = async (ids: string[], locales: string[]): Promise => { }); }; -/** - * A row as the plugin wrote them before document references became ID-agnostic. - * - * `storedAs` writes the id through the adapter rather than through the field, which is the only way - * to produce the shape that matters here: a legacy id stored as a **number**. Measured on all three - * adapters, a `where` comparing such a value against a document id finds nothing — so a document - * narrowing that matched the legacy id would drop the row silently. - */ -const seedLegacyRow = async ( - collectionSlug: string, - documentId: string, - storedAs?: unknown, - targetLng = "de" -): Promise => { - const input = { - collection: { relationTo: collectionSlug, value: documentId }, - source_lng: "en", - targets: [{ lng: targetLng }], - }; - const row = await ctx.payload.create({ - collection: JOBS, - data: { workflowSlug: WORKFLOW, queue: QUEUE, input }, - } as never); - if (storedAs === undefined) return; - await ctx.payload.db.updateOne({ - collection: JOBS, - id: (row as { id: unknown }).id, - data: { input: { ...input, collection: { relationTo: collectionSlug, value: storedAs } } }, - returning: false, - } as never); -}; - -const seedForeignRow = async (collectionSlug: string): Promise => { +const seedForeignRun = async (collectionSlug: string): Promise => { await ctx.payload.create({ - collection: JOBS, + collection: RUNS, data: { - workflowSlug: WORKFLOW, - queue: QUEUE, - input: { - collection_slug: collectionSlug, - collection_id: "999", - source_lng: "en", - targets: [{ lng: "de" }], - }, + handle: `foreign-${collectionSlug}`, + collectionSlug, + collectionId: "999", + sourceLng: "en", + targetLng: "de", + publishOnTranslation: false, + queuedAt: new Date().toISOString(), }, } as never); }; -/** SQL ids are integers; Mongo ids are strings, where the number case cannot arise. */ -const numericIfPossible = (id: string): unknown => (/^\d+$/u.test(id) ? Number(id) : id); +const slugOf = (row: RunRow): unknown => row.collectionSlug; -const slugOf = (row: JobRow): unknown => - row.input?.collection_slug ?? (row.input?.collection as { relationTo?: unknown })?.relationTo; - -const documentOf = (row: JobRow): unknown => - row.input?.collection_id ?? (row.input?.collection as { value?: unknown })?.value; +const documentOf = (row: RunRow): unknown => row.collectionId; describe("the status endpoints read only what they answer about", () => { beforeAll(async () => { @@ -129,12 +89,12 @@ describe("the status endpoints read only what they answer about", () => { restore = undefined; }); - it("loads no job row belonging to another collection", async () => { + it("loads no run row belonging to another collection", async () => { const id = await create("Ours"); await enqueue([id], ["de"]); - await seedForeignRow("somewhere-else"); + await seedForeignRun("somewhere-else"); - watchJobReads(); + watchRunReads(); await callEndpoint(ctx.payload, "get", "/translate/collection/:collection_slug", { routeParams: { collection_slug: "docs" }, }); @@ -148,56 +108,20 @@ describe("the status endpoints read only what they answer about", () => { ).toEqual([]); }); - it("still finds a row written in the legacy relationship shape", async () => { - const id = await create("Legacy"); - await seedLegacyRow("docs", id); - - const res = await callEndpoint(ctx.payload, "get", "/translate/collection/:collection_slug", { - routeParams: { collection_slug: "docs" }, - }); - - const docs = (res.data as { data: { docs: Array<{ collection_id: string }> } }).data.docs; - expect( - docs.map((entry) => entry.collection_id), - "rows written before the ID-agnostic migration still answer" - ).toContain(id); - }); - - it("finds a legacy row by document whichever way its id was stored", async () => { - const id = await create("Legacy, asked by document"); - await seedLegacyRow("docs", id, id, "de"); - await seedLegacyRow("docs", id, numericIfPossible(id), "fr"); - - const res = await callEndpoint( - ctx.payload, - "get", - "/translate/document/:collection_slug/:collection_id", - { routeParams: { collection_slug: "docs", collection_id: id } } - ); - - const body = res.data as { data: Array<{ input: { target_lng: string } }> }; - expect( - body.data.map((task) => task.input.target_lng).sort(), - "a relationship field stored a numeric collection's id as a number; the narrowing must offer both forms" - ).toEqual(["de", "fr"]); - }); - - it("loads only the asked document's rows, for rows written in the flat shape", async () => { + it("loads only the asked document's rows", async () => { const asked = await create("Asked about"); const other = await create("Not asked about"); await enqueue([asked, other], ["de"]); - watchJobReads(); + watchRunReads(); await callEndpoint(ctx.payload, "get", "/translate/document/:collection_slug/:collection_id", { routeParams: { collection_slug: "docs", collection_id: asked }, }); - const flatRowsForOthers = rowsRead().filter( - (row) => row.input?.collection_slug !== undefined && documentOf(row) !== asked - ); + const rowsForOthers = rowsRead().filter((row) => documentOf(row) !== asked); expect(rowsRead().length, "the read happened at all").toBeGreaterThan(0); expect( - flatRowsForOthers.map(documentOf), + rowsForOthers.map(documentOf), "one document's panel does not read another document's runs" ).toEqual([]); }); diff --git a/apps/dev/src/integration/translator/task-runner-contract.int.test.ts b/apps/dev/src/integration/translator/task-runner-contract.int.test.ts index 291f0c5b1..6ebbbadd3 100644 --- a/apps/dev/src/integration/translator/task-runner-contract.int.test.ts +++ b/apps/dev/src/integration/translator/task-runner-contract.int.test.ts @@ -13,10 +13,14 @@ afterAll(async () => { await ctx?.cleanup(); }); -assertTaskRunnerContract("PayloadJobsTaskRunner", (report) => - createPayloadJobsRunner({ autoRun: false }).create(ctx.payload, { - handler: vi.fn().mockResolvedValue(undefined), - collections: [], - report, - }) +const provider = createPayloadJobsRunner({ autoRun: false }); + +assertTaskRunnerContract( + "PayloadJobsTaskRunner", + (report) => + provider.create(ctx.payload, { + handler: vi.fn().mockResolvedValue(undefined), + report, + }), + provider.capabilities ); diff --git a/apps/dev/src/migrations/20261026_214339_create_translator_runs.ts b/apps/dev/src/migrations/20261026_214339_create_translator_runs.ts new file mode 100644 index 000000000..1c0809b19 --- /dev/null +++ b/apps/dev/src/migrations/20261026_214339_create_translator_runs.ts @@ -0,0 +1,83 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-sqlite' + +export async function up({ db, payload, req }: MigrateUpArgs): Promise { + await db.run(sql`CREATE TABLE \`translator_runs\` ( + \`id\` integer PRIMARY KEY NOT NULL, + \`handle\` text NOT NULL, + \`collection_slug\` text NOT NULL, + \`collection_id\` text NOT NULL, + \`source_lng\` text NOT NULL, + \`target_lng\` text NOT NULL, + \`filters\` text, + \`publish_on_translation\` integer DEFAULT false NOT NULL, + \`requester_id\` text, + \`requester_collection\` text, + \`queued_at\` text NOT NULL, + \`started_at\` text, + \`settled_at\` text, + \`outcome\` text, + \`error\` text, + \`attempts\` numeric, + \`updated_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + \`created_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL + ); + `) + await db.run(sql`CREATE INDEX \`translator_runs_handle_idx\` ON \`translator_runs\` (\`handle\`);`) + await db.run(sql`CREATE INDEX \`translator_runs_settled_at_idx\` ON \`translator_runs\` (\`settled_at\`);`) + await db.run(sql`CREATE INDEX \`translator_runs_updated_at_idx\` ON \`translator_runs\` (\`updated_at\`);`) + await db.run(sql`CREATE INDEX \`translator_runs_created_at_idx\` ON \`translator_runs\` (\`created_at\`);`) + await db.run(sql`CREATE UNIQUE INDEX \`handle_targetLng_idx\` ON \`translator_runs\` (\`handle\`,\`target_lng\`);`) + await db.run(sql`CREATE INDEX \`collectionSlug_collectionId_idx\` ON \`translator_runs\` (\`collection_slug\`,\`collection_id\`);`) + await db.run(sql`ALTER TABLE \`payload_locked_documents_rels\` ADD \`translator_runs_id\` integer REFERENCES translator_runs(id);`) + await db.run(sql`CREATE INDEX \`payload_locked_documents_rels_translator_runs_id_idx\` ON \`payload_locked_documents_rels\` (\`translator_runs_id\`);`) +} + +export async function down({ db, payload, req }: MigrateDownArgs): Promise { + await db.run(sql`DROP TABLE \`translator_runs\`;`) + await db.run(sql`PRAGMA foreign_keys=OFF;`) + await db.run(sql`CREATE TABLE \`__new_payload_locked_documents_rels\` ( + \`id\` integer PRIMARY KEY NOT NULL, + \`order\` integer, + \`parent_id\` integer NOT NULL, + \`path\` text NOT NULL, + \`users_id\` integer, + \`media_id\` integer, + \`pages_id\` integer, + \`articles_id\` integer, + \`playground_id\` integer, + \`ab_experiments_id\` integer, + \`presets_id\` integer, + \`comments_id\` integer, + \`comment_reads_id\` integer, + \`translator_provenance_id\` integer, + FOREIGN KEY (\`parent_id\`) REFERENCES \`payload_locked_documents\`(\`id\`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (\`users_id\`) REFERENCES \`users\`(\`id\`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (\`media_id\`) REFERENCES \`media\`(\`id\`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (\`pages_id\`) REFERENCES \`pages\`(\`id\`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (\`articles_id\`) REFERENCES \`articles\`(\`id\`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (\`playground_id\`) REFERENCES \`playground\`(\`id\`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (\`ab_experiments_id\`) REFERENCES \`ab_experiments\`(\`id\`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (\`presets_id\`) REFERENCES \`presets\`(\`id\`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (\`comments_id\`) REFERENCES \`comments\`(\`id\`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (\`comment_reads_id\`) REFERENCES \`comment_reads\`(\`id\`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (\`translator_provenance_id\`) REFERENCES \`translator_provenance\`(\`id\`) ON UPDATE no action ON DELETE cascade + ); + `) + await db.run(sql`INSERT INTO \`__new_payload_locked_documents_rels\`("id", "order", "parent_id", "path", "users_id", "media_id", "pages_id", "articles_id", "playground_id", "ab_experiments_id", "presets_id", "comments_id", "comment_reads_id", "translator_provenance_id") SELECT "id", "order", "parent_id", "path", "users_id", "media_id", "pages_id", "articles_id", "playground_id", "ab_experiments_id", "presets_id", "comments_id", "comment_reads_id", "translator_provenance_id" FROM \`payload_locked_documents_rels\`;`) + await db.run(sql`DROP TABLE \`payload_locked_documents_rels\`;`) + await db.run(sql`ALTER TABLE \`__new_payload_locked_documents_rels\` RENAME TO \`payload_locked_documents_rels\`;`) + await db.run(sql`PRAGMA foreign_keys=ON;`) + await db.run(sql`CREATE INDEX \`payload_locked_documents_rels_order_idx\` ON \`payload_locked_documents_rels\` (\`order\`);`) + await db.run(sql`CREATE INDEX \`payload_locked_documents_rels_parent_idx\` ON \`payload_locked_documents_rels\` (\`parent_id\`);`) + await db.run(sql`CREATE INDEX \`payload_locked_documents_rels_path_idx\` ON \`payload_locked_documents_rels\` (\`path\`);`) + await db.run(sql`CREATE INDEX \`payload_locked_documents_rels_users_id_idx\` ON \`payload_locked_documents_rels\` (\`users_id\`);`) + await db.run(sql`CREATE INDEX \`payload_locked_documents_rels_media_id_idx\` ON \`payload_locked_documents_rels\` (\`media_id\`);`) + await db.run(sql`CREATE INDEX \`payload_locked_documents_rels_pages_id_idx\` ON \`payload_locked_documents_rels\` (\`pages_id\`);`) + await db.run(sql`CREATE INDEX \`payload_locked_documents_rels_articles_id_idx\` ON \`payload_locked_documents_rels\` (\`articles_id\`);`) + await db.run(sql`CREATE INDEX \`payload_locked_documents_rels_playground_id_idx\` ON \`payload_locked_documents_rels\` (\`playground_id\`);`) + await db.run(sql`CREATE INDEX \`payload_locked_documents_rels_ab_experiments_id_idx\` ON \`payload_locked_documents_rels\` (\`ab_experiments_id\`);`) + await db.run(sql`CREATE INDEX \`payload_locked_documents_rels_presets_id_idx\` ON \`payload_locked_documents_rels\` (\`presets_id\`);`) + await db.run(sql`CREATE INDEX \`payload_locked_documents_rels_comments_id_idx\` ON \`payload_locked_documents_rels\` (\`comments_id\`);`) + await db.run(sql`CREATE INDEX \`payload_locked_documents_rels_comment_reads_id_idx\` ON \`payload_locked_documents_rels\` (\`comment_reads_id\`);`) + await db.run(sql`CREATE INDEX \`payload_locked_documents_rels_translator_provenance_id_idx\` ON \`payload_locked_documents_rels\` (\`translator_provenance_id\`);`) +} diff --git a/apps/dev/src/migrations/index.ts b/apps/dev/src/migrations/index.ts index 9287a1da9..4164213ee 100644 --- a/apps/dev/src/migrations/index.ts +++ b/apps/dev/src/migrations/index.ts @@ -1,69 +1,75 @@ -import * as migration_20260316_220744_init from "./20260316_220744_init"; -import * as migration_20260326_120907_content_releases from "./20260326_120907_content_releases"; -import * as migration_20260420_140729_create_preset_per_block_tables from "./20260420_140729_create_preset_per_block_tables"; -import * as migration_20260422_214318_add_rollback_skipped_column_to_releases from "./20260422_214318_add_rollback_skipped_column_to_releases"; -import * as migration_20260508_140239_align_schema_and_localize from "./20260508_140239_align_schema_and_localize"; -import * as migration_20260512_104342_localize_block_fields from "./20260512_104342_localize_block_fields"; -import * as migration_20260518_215435_create_comment_reads from "./20260518_215435_create_comment_reads"; -import * as migration_20260529_121405_create_ab_experiments from "./20260529_121405_create_ab_experiments"; -import * as migration_20260602_230716_add_hidden_to_sections from "./20260602_230716_add_hidden_to_sections"; -import * as migration_20260614_230241_add_content_block_hero_media_cta_remove_copy from "./20260614_230241_add_content_block_hero_media_cta_remove_copy"; -import * as migration_20260707_125950_create_translator_provenance from "./20260707_125950_create_translator_provenance"; +import * as migration_20260316_220744_init from './20260316_220744_init'; +import * as migration_20260326_120907_content_releases from './20260326_120907_content_releases'; +import * as migration_20260420_140729_create_preset_per_block_tables from './20260420_140729_create_preset_per_block_tables'; +import * as migration_20260422_214318_add_rollback_skipped_column_to_releases from './20260422_214318_add_rollback_skipped_column_to_releases'; +import * as migration_20260508_140239_align_schema_and_localize from './20260508_140239_align_schema_and_localize'; +import * as migration_20260512_104342_localize_block_fields from './20260512_104342_localize_block_fields'; +import * as migration_20260518_215435_create_comment_reads from './20260518_215435_create_comment_reads'; +import * as migration_20260529_121405_create_ab_experiments from './20260529_121405_create_ab_experiments'; +import * as migration_20260602_230716_add_hidden_to_sections from './20260602_230716_add_hidden_to_sections'; +import * as migration_20260614_230241_add_content_block_hero_media_cta_remove_copy from './20260614_230241_add_content_block_hero_media_cta_remove_copy'; +import * as migration_20260707_125950_create_translator_provenance from './20260707_125950_create_translator_provenance'; +import * as migration_20261026_214339_create_translator_runs from './20261026_214339_create_translator_runs'; export const migrations = [ { up: migration_20260316_220744_init.up, down: migration_20260316_220744_init.down, - name: "20260316_220744_init", + name: '20260316_220744_init', }, { up: migration_20260326_120907_content_releases.up, down: migration_20260326_120907_content_releases.down, - name: "20260326_120907_content_releases", + name: '20260326_120907_content_releases', }, { up: migration_20260420_140729_create_preset_per_block_tables.up, down: migration_20260420_140729_create_preset_per_block_tables.down, - name: "20260420_140729_create_preset_per_block_tables", + name: '20260420_140729_create_preset_per_block_tables', }, { up: migration_20260422_214318_add_rollback_skipped_column_to_releases.up, down: migration_20260422_214318_add_rollback_skipped_column_to_releases.down, - name: "20260422_214318_add_rollback_skipped_column_to_releases", + name: '20260422_214318_add_rollback_skipped_column_to_releases', }, { up: migration_20260508_140239_align_schema_and_localize.up, down: migration_20260508_140239_align_schema_and_localize.down, - name: "20260508_140239_align_schema_and_localize", + name: '20260508_140239_align_schema_and_localize', }, { up: migration_20260512_104342_localize_block_fields.up, down: migration_20260512_104342_localize_block_fields.down, - name: "20260512_104342_localize_block_fields", + name: '20260512_104342_localize_block_fields', }, { up: migration_20260518_215435_create_comment_reads.up, down: migration_20260518_215435_create_comment_reads.down, - name: "20260518_215435_create_comment_reads", + name: '20260518_215435_create_comment_reads', }, { up: migration_20260529_121405_create_ab_experiments.up, down: migration_20260529_121405_create_ab_experiments.down, - name: "20260529_121405_create_ab_experiments", + name: '20260529_121405_create_ab_experiments', }, { up: migration_20260602_230716_add_hidden_to_sections.up, down: migration_20260602_230716_add_hidden_to_sections.down, - name: "20260602_230716_add_hidden_to_sections", + name: '20260602_230716_add_hidden_to_sections', }, { up: migration_20260614_230241_add_content_block_hero_media_cta_remove_copy.up, down: migration_20260614_230241_add_content_block_hero_media_cta_remove_copy.down, - name: "20260614_230241_add_content_block_hero_media_cta_remove_copy", + name: '20260614_230241_add_content_block_hero_media_cta_remove_copy', }, { up: migration_20260707_125950_create_translator_provenance.up, down: migration_20260707_125950_create_translator_provenance.down, - name: "20260707_125950_create_translator_provenance", + name: '20260707_125950_create_translator_provenance', + }, + { + up: migration_20261026_214339_create_translator_runs.up, + down: migration_20261026_214339_create_translator_runs.down, + name: '20261026_214339_create_translator_runs' }, ]; diff --git a/apps/dev/src/payload-types.ts b/apps/dev/src/payload-types.ts index 142b243f7..5148b8194 100644 --- a/apps/dev/src/payload-types.ts +++ b/apps/dev/src/payload-types.ts @@ -77,6 +77,7 @@ export interface Config { comments: Comment; 'comment-reads': CommentRead; 'translator-provenance': TranslatorProvenance; + 'translator-runs': TranslatorRun; 'payload-kv': PayloadKv; 'payload-jobs': PayloadJob; 'payload-locked-documents': PayloadLockedDocument; @@ -95,6 +96,7 @@ export interface Config { comments: CommentsSelect | CommentsSelect; 'comment-reads': CommentReadsSelect | CommentReadsSelect; 'translator-provenance': TranslatorProvenanceSelect | TranslatorProvenanceSelect; + 'translator-runs': TranslatorRunsSelect | TranslatorRunsSelect; 'payload-kv': PayloadKvSelect | PayloadKvSelect; 'payload-jobs': PayloadJobsSelect | PayloadJobsSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; @@ -750,6 +752,30 @@ export interface TranslatorProvenance { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "translator-runs". + */ +export interface TranslatorRun { + id: number; + handle: string; + collectionSlug: string; + collectionId: string; + sourceLng: string; + targetLng: string; + filters?: string | null; + publishOnTranslation: boolean; + requesterId?: string | null; + requesterCollection?: string | null; + queuedAt: string; + startedAt?: string | null; + settledAt?: string | null; + outcome?: string | null; + error?: string | null; + attempts?: number | null; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-kv". @@ -906,6 +932,10 @@ export interface PayloadLockedDocument { | ({ relationTo: 'translator-provenance'; value: number | TranslatorProvenance; + } | null) + | ({ + relationTo: 'translator-runs'; + value: number | TranslatorRun; } | null); globalSlug?: string | null; user: { @@ -1300,6 +1330,29 @@ export interface TranslatorProvenanceSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "translator-runs_select". + */ +export interface TranslatorRunsSelect { + handle?: T; + collectionSlug?: T; + collectionId?: T; + sourceLng?: T; + targetLng?: T; + filters?: T; + publishOnTranslation?: T; + requesterId?: T; + requesterCollection?: T; + queuedAt?: T; + startedAt?: T; + settledAt?: T; + outcome?: T; + error?: T; + attempts?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-kv_select". diff --git a/packages/payload-plugin-translator/docs/DEPRECATIONS.md b/packages/payload-plugin-translator/docs/DEPRECATIONS.md index f1abf28de..89e8302ae 100644 --- a/packages/payload-plugin-translator/docs/DEPRECATIONS.md +++ b/packages/payload-plugin-translator/docs/DEPRECATIONS.md @@ -59,11 +59,16 @@ the single source of truth — code annotations link here by anchor instead of d - **Migration:** expand/contract. New jobs write text fields; the relationship field stays as a read-only fallback (`required: false`, `admin.readOnly: true`) so jobs queued before the change remain readable. Removed (along with the fallback read path) in the next major. +- **Where the fallback lives now:** one folder — + `src/server/modules/task-runner/payload-jobs-runner/legacy/` — reached from two call sites, the + boot backfill and the workflow handler. A job carrying any pre-1.0 shape is **adopted** on first + encounter: its rows are written into `translator-runs` from what the old row said, its input is + replaced by the pointer a job carries now, and nothing downstream learns the old shape existed. + Deleting that folder, its two call sites and the `withoutAHandle()` query drops the compatibility + outright — the cost is that a job queued before the upgrade and never adopted stops running. - **Code refs:** - - `src/server/modules/task-runner/payload-jobs-runner/PayloadJobsRunnerProvider.ts` (inputSchema, handler input type/unpacking) - - `src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.ts` (enqueue write, `findByCollection` / `findRawJobs` query + in-memory filter) - - `src/server/modules/task-runner/payload-jobs-runner/store/normalizeJob.ts` (read fallback) - - `src/server/modules/task-runner/payload-jobs-runner/store/types.ts` (`PayloadJob.input` shape) + - `src/server/modules/task-runner/payload-jobs-runner/legacy/legacyInput.ts` (the only reader) + - `src/server/modules/task-runner/payload-jobs-runner/legacy/adoptLegacyJob.ts` ### jobs-per-locale-task-shape @@ -77,14 +82,49 @@ the single source of truth — code annotations link here by anchor instead of d - **Why:** a job per locale meant Payload ran a document's locales through `Promise.all`, and every write it makes is a whole-document version snapshot — so the second locale's snapshot dropped the first's work. See [locale workflow](./plans/2026-09-04-locale-workflow.task.md). -- **Migration:** expand/contract. Nothing writes the task shape any more, but rows queued before the - upgrade are still in `payload-jobs`, so every read keeps a fallback for them. Removing the fallback - strands those rows: cancel, stale-lock reclaim and the status panels stop finding them, silently. +- **Migration:** expand/contract. Nothing writes the task shape any more, and a row queued before the + upgrade is adopted rather than read in parallel — see the entry above for where that lives. +- **Where the fallback lives now:** one folder — + `src/server/modules/task-runner/payload-jobs-runner/legacy/` — reached from two call sites, the + boot backfill and the workflow handler. A job carrying any pre-1.0 shape is **adopted** on first + encounter: its rows are written into `translator-runs` from what the old row said, its input is + replaced by the pointer a job carries now, and nothing downstream learns the old shape existed. + Deleting that folder, its two call sites and the `withoutAHandle()` query drops the compatibility + outright — the cost is that a job queued before the upgrade and never adopted stops running. - **Code refs:** - `src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.ts` (`ownJobs()`) - - `src/server/modules/task-runner/payload-jobs-runner/model/planEnqueue.ts` (`pickHost` skips it) - - `src/server/modules/task-runner/payload-jobs-runner/store/normalizeJob.ts` (`normalizeJobLocales` - expands it to itself) + - `src/server/modules/task-runner/payload-jobs-runner/legacy/legacyInput.ts` + +### the-runner-only-accepts-work + +- **What:** `TaskRunner.assignmentsOf` and `TaskRunner.findByCollection`, with `TaskFilter` and + `toTaskFilter`; `TaskRunner.cancel` and `TaskRunner.run` as required members; + `createSyncRunner`'s `maxSize` / `ttlMs` options and the `SyncRunnerOptions` type. +- **Status:** removed +- **Deprecated:** not annotated — agreed and removed in one step, because the replacement had to + exist first. +- **Removed:** 2026-10-27 +- **Replacement:** nothing on the runner. The plugin keeps its own record of every run + (`translator-runs`) and answers from it: which runs a document has, which document a handle names, + and what each locale is doing. `cancel` becomes the optional `stop`; `run` becomes optional; a + provider declares which of the two it has through the required `capabilities: RunnerCapabilities`. +- **Why:** every fact the plugin needed about a run lived in the runner, so each new need became a + new port member and a new demand on every runner author — `assignmentsOf` arrived that way. Status + also could not survive Payload's own `deleteJobOnComplete: true`, because the job row was the + record. See [the plugin keeps its own record](./plans/2026-10-26-the-plugin-keeps-its-own-record.design.md). +- **Migration:** implement `enqueue` and declare `capabilities`. A runner that can stop work renames + `cancel` to `stop` and declares `stop: true`; one that can process a queued job on demand keeps + `run` and declares `run: true`. Delete `assignmentsOf` and `findByCollection` — nothing calls + them. `assertTaskRunnerContract` now takes the declared capabilities as its third argument and + asserts that what a provider declares, its runner implements. +- **What removal takes with it:** a route whose capability is absent is **not registered**, so a + host's own client calling `/translate/cancel` against an enqueue-only runner gets a 404 rather + than a 501. The plugin's own admin draws no control for it. +- **Code refs:** + - `src/server/modules/task-runner/TaskRunner.interface.ts` + - `src/server/modules/task-runner/RunnerCapabilities.ts` + - `src/server/features/createTranslationRoutes.ts` (registration by capability) + - `src/testing/TaskRunner.invariants.ts` ### translate-positional-languages @@ -112,9 +152,11 @@ the single source of truth — code annotations link here by anchor instead of d - **What:** passing a bare `Array` of document ids as the second argument to `TaskRunner.findByCollection`. -- **Status:** live +- **Status:** removed - **Deprecated:** 2026-09-04 / PR #126 -- **Replacement:** `TaskFilter` — `findByCollection(slug, { documentIds })`. +- **Removed:** 2026-10-27 — with `findByCollection` itself; see + [the-runner-only-accepts-work](#the-runner-only-accepts-work). +- **Replacement:** nothing on the runner. The plugin answers about a collection from its own record. - **Remove in:** next major - **Why:** a positional argument cannot grow. The second filter this method needed — `excludeCompleted`, which bounds the read on the enqueue path (#108) — had nowhere to go without @@ -301,8 +343,11 @@ the single source of truth — code annotations link here by anchor instead of d ### enqueue-void-return - **What:** `TaskRunner.enqueue` resolving with nothing. -- **Status:** live +- **Status:** removed - **Deprecated:** 2026-10-07 / PR pending (issues #107, #110) +- **Removed:** 2026-10-27 — `enqueue` now answers `EnqueueAssignment[]`, and the plugin writes a + row of its own against each handle, so a runner that answered with nothing would leave work it + accepted unrecorded. - **Replacement:** resolve with one assignment per requested locale — the shape `TaskRunnerProvider` infers for `enqueue`; an implementation returns it as an object literal and needs no import. - **Remove in:** next major @@ -485,7 +530,9 @@ keeps flagging until the source has text again. ### the-strategy-leaves-the-stored-run - **What:** reading `target_lngs` and `strategy` off a run row written before 1.0. -- **Status:** read-only shim, written nowhere +- **Status:** read-only shim, written nowhere, and since 2026-10-27 confined to + `payload-jobs-runner/legacy/` — a row carrying this shape is adopted on first encounter and is a + pointer from that moment on. - **Changed:** 2026-10-23 / #208 - **Replacement:** `targets: [{ lng, filters }]`. A legacy row is read as one target per listed locale, and a row that named `skip_existing` is read as carrying one `rule` filter named `stale`. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-26-the-plugin-keeps-its-own-record.design.md b/packages/payload-plugin-translator/docs/plans/2026-10-26-the-plugin-keeps-its-own-record.design.md new file mode 100644 index 000000000..12ff79a78 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-26-the-plugin-keeps-its-own-record.design.md @@ -0,0 +1,363 @@ +# The plugin keeps its own record of runs + +Step 6 of the next major (#200, part of #192). Supersedes the framing in #200, which asked only to +make the runner's methods optional. + +## Why + +`TaskRunner` has five required members: `enqueue`, `cancel`, `run`, `assignmentsOf`, +`findByCollection`. It keeps growing for one reason — **the plugin has no record of its own work**. +Every fact it needs about a run lives in the runner, so every new need becomes a new method and a new +demand on every runner author. `assignmentsOf` was added days ago for exactly this: `/cancel` and +`/run/:id` receive a handle, and Payload's `access.update` rule is asked about a *document*, never a +handle. + +Three host-facing warts in the README share that root: + +| README | What the host is told today | +|---|---| +| `:401` | Payload deletes a job on completion (`deleteJobOnComplete` defaults to `true`), so "Completed" never appears in the panels — flip a Payload option yourself | +| `:403` | pruning what then accumulates is yours to manage | +| `:398` | `createSyncRunner` keeps status in memory — last 100 tasks, one hour, forgotten on restart | + +And the reads are unbounded: `findRawJobs` is `pagination: false` with no limit or sort +(`PayloadJobsTaskRunner.ts:363-376`); the collection panel ships one row per live locale so the +client can reduce it to three counters; `enqueue` reads **every live job in the installation** +(`:99`). Filed as #123, measured across adapters in `2026-10-12-bound-the-status-reads.md`. + +## Requirements + +- R1 (in) The plugin answers "which runs exist for this document" from its own store, not from the + runner. +- R2 (in) The plugin answers "which document does this handle name" from its own store, so + `assignmentsOf` leaves the port. +- R3 (in) Run status survives Payload's default `deleteJobOnComplete: true`, with no option for the + host to flip. +- R4 (in) One run per document at a time: a request for a document with an unsettled run is refused, + and the refusal is told apart from a refusal by rights. +- R5 (in) The mandatory runner surface is `enqueue`; `stop` and `run` are capabilities, and a route + whose capability is missing is not registered. +- R6 (in) A run is never recorded silently as absent: a failed record write fails the enqueue. +- R7 (in) A stored error carries no provider detail. +- R8 (in) Jobs queued before the upgrade keep running and stay cancellable, and the compatibility + that allows it is removable from one file. +- R9 (out) Retention or pruning inside the plugin — settled by #122. +- R10 (out) Reconciling with the runner's storage, and any "abandoned" state derived from age. +- R11 (out) The field-picker tree and the admin forms — #48. + +## Decision points + +- D1 [load-bearing] Our record is the source of truth; the runner's storage is never read. +- D2 [load-bearing] One run per document — blocking, not merging. +- D3 [load-bearing] The table stores facts, not a status: `settledAt` plus `outcome`. +- D4 [load-bearing] The job input becomes a pointer, so no fact is stored twice. +- D5 [load-bearing] The record write joins the caller's transaction and its failure fails the + enqueue. +- D6 [minor] The table is mandatory, unlike provenance. +- D7 [minor] `error` is sanitised at write; one column, no reason code until a consumer branches. +- D8 [minor] Backward compatibility is adoption in one file, not a fallback path. +- D9 [minor] `startedAt` is the plugin's own stamp, via `handle` on `TaskHandlerInput`. + +## Requirement coverage + +| | Design element | +|---|---| +| R1 | the `translator-runs` table, read by the four routes | +| R2 | the `(handle, targetLng)` unique key; `assignmentsOf` deleted | +| R3 | `outcome` and `settledAt` written from `context.report`, surviving the job row | +| R4 | blocking in the surviving run policy; two refusal meanings on `POST /enqueue` | +| R5 | the narrowed `TaskRunner`; routes registered by capability | +| R6 | the transaction rule — a `TranslatorError` with `mustPropagate = true` | +| R7 | `toClientErrorMessage` applied before the write | +| R8 | `adoptLegacyJob`, called from the boot backfill and from the provider | +| R9 | not built — #122 | +| R10 | not built — see "Deliberately not done" | +| R11 | not built — #48 | + +## Decisions + +- **Our record is the source of truth.** The runner's storage is never read. No `statesOf`, no + reconciliation, no second opinion. +- **One run per document at a time.** A request for a document that already has an unsettled run is + refused, not merged. This replaces the whole merge mechanism and removes the hazard it was built + around. +- **The table stores facts, not a status.** Timestamps of what happened plus what the settlement was; + the status vocabulary is derived, so changing it needs no migration. +- **The table is mandatory**, unlike provenance: cancel, run-now and both status routes rest on it. +- **Pruning stays the host's job** (#122, closed 2026-09-04 by PR #129) — with one new rule: a row + whose `settledAt` is empty must not be pruned, because the queue now holds only a pointer. +- **The job input becomes a pointer.** No duplication of facts between the queue and our table. +- **Backward compatibility is one file**, not a fallback path threaded through the code. + +### Why blocking rather than merging + +Two runs over one document lose each other's work, and it cannot be fixed at the write. Verified in +Payload 3.90.1: `collections/operations/utilities/update.js:284` builds `dataToUpdate` as the whole +document with every locale, and `@payloadcms/drizzle/upsertRow/index.js:233` writes a row for every +locale present. A locale-scoped update chooses *where our fields land*, not *what gets rewritten*. So +run B, having read the document before run A committed, puts A's German back as it was. + +Today that is survived by merging requests into one run and by asking the host to set +`jobs.enableConcurrencyControl`. Merging costs `planEnqueue`, `alreadyCovered`, `covers` and +`extendJob` — and still leaves residual cases the flag must cover. + +Blocking removes all of it. The cost is an editor who wants a third locale mid-run must cancel or +wait. The failure mode is honest: a run stuck unsettled blocks its document until someone cancels, +and cancel is a button that already exists. + +## The port, after + +```ts +interface TaskRunner { + enqueue(tasks: AuthorisedWork[], scope?: RequestScope): Promise; + stop?(handles: AuthorisedHandle[]): Promise; + run?(handle: AuthorisedHandle): Promise; +} +``` + +Five members become three; one is required. `run` stays as its own capability — it means *process +this queued job now*, and on serverless hosts it is the only way the queue moves (README `:393`). +A route whose member is missing is not registered and its control is not drawn. `SyncTaskRunner` +implements `enqueue` alone. + +## The table + +Modelled on the one precedent, `Provenance.collection.ts`: `admin.hidden: true`, all four `access` +rules `() => false`, written through the Local API, portable field types. + +```ts +// translator-runs — one row per (handle, targetLng) +handle text index +collectionSlug text ┐ compound index +collectionId text ┘ +sourceLng text +targetLng text +filters text null // JSON: what this locale is asked to translate +publishOnTranslation checkbox +requesterId text null +requesterCollection text null +queuedAt date +startedAt date null +settledAt date null index +outcome text null // delivered | failed | cancelled +error text null // already client-safe; see below +attempts number null + +indexes: [ + { fields: ["handle", "targetLng"], unique: true }, + { fields: ["collectionSlug", "collectionId"] }, +] +``` + +**Grain** — one row per `EnqueueAssignment`, which is what `context.report` delivers. + +**Upsert, not insert.** `report` sends one `queued` per assignment including a locale an existing run +already covers, so the same key can arrive twice. Follow `Provenance.store.upsert`. + +**Status is derived:** `outcome` set → that outcome · `startedAt` set → running · else pending. +`settledAt` exists only so "in flight" is one indexed predicate, the shape the code already uses +(`completedAt: { exists: false }`). + +**`startedAt` is the plugin's own stamp.** Running means the translation handler was called, and that +handler is the plugin's (`wireTranslateRunner.ts:47`). No `running` is added to `TaskEvent`; instead +`TaskHandlerInput` gains `handle`, which is already in scope at both call sites. + +**`error` is sanitised at write, not at read.** Today the raw provider message — which can carry an +API key, the case `toClientErrorMessage` names — is stored in `payload-jobs.error` and cleaned only on +the way to the browser. Here `toClientErrorMessage` runs before the write, so the secret never +reaches the database. One column, text: four of the five failure markers are plugin-raised, and the +common provider failures (timeout, rate limit, 5xx) carry no code at all, so a code column would say +"unknown" for most real failures and has no consumer that branches on it. + +**`filters` is parsed defensively.** The rule `runTargets` already states carries over with the data: +an entry the parser rejects is **dropped**, never read as naming no filter — no filters means the +whole document, so a corrupt value would turn a narrowed run into one that overwrites reviewed text. + +**Caveat, written once in the code:** `TaskEvent` carries no timestamp, so `settledAt` is *when the +plugin was told*. `queuedAt` and `startedAt` are the plugin's own clock. + +## The transaction rule + +The row write **joins the caller's transaction, and its failure fails `enqueue`.** A lost record is +not a reporting gap: the next request would see no live run, queue a second one over the same +document, and the host pays twice for a translation that loses work. + +The mechanism already exists. A failed row write raises a `TranslatorError` with +`mustPropagate = true`, and `swallowOrThrow:18,24` does the rest: inside a transaction it propagates, +outside it does not. `PermissionCheckFailed` is already classified this way. On the auto-translate +path this means the editor's save fails — which is correct, and factually unavoidable, since a failed +write inside a transaction has usually killed it already. + +**Cost on that path, in operations.** Today the plugin's bookkeeping costs **zero** database +operations per locale under `SyncTaskRunner`: `report` only calls `LifecycleNotifier`. After, each +locale costs a `queued` write, a row read, a `startedAt` write and a `settledAt` write — four, inside +the editor's save. The read is removable (the sync runner just wrote the row in-process); the writes +are not. + +## Refusal has two meanings now + +`POST /enqueue` can refuse for two unrelated reasons, and they must not share a field: **no rights on +the document**, and **the document is busy**. A bulk request over many documents answers per +document — the free ones are queued, the busy ones are named. + +When the editor re-queues after cancelling, the request is composed **from what is still owed**: a +locale already delivered is not asked for again. Otherwise cancelling costs money. + +## What moves + +| Moves | From | To | +|---|---|---| +| what survives of the run policy | `payload-jobs-runner/model/` | `src/core/domain/runs/` | +| `alreadyCovered`, `covers`, `extendJob`, `planEnqueue`'s merge branches | — | **deleted** — blocking replaces them | +| the stored run shape (`jobInput.schema`, `runTargets`, `normalizeJob`, `readCollectionRef`, the legacy readers) | `payload-jobs-runner/store/` | the table, plus `adoptLegacyJob` | +| `retryLimitOf`, `isLastAttempt`, `attemptsBefore` | — | **stay**: they read this runner's own configuration | +| `context.collections` | `TaskRunnerContext` | **gone** — its only two uses build the legacy `collection` field this step removes | + +`core/` cannot import Payload (package `CLAUDE.md`), which is the point: the policy structurally +cannot reach back into job rows. **`serialisesPerDocument` is not needed** — blocking makes +concurrent runs impossible by construction rather than by the host's flag. + +## Backward compatibility, in one file + +Legacy job handling today lives in six files, which is why nobody can remove it. This time an +old-shaped job is **adopted** rather than read in parallel: on first encounter it writes our rows and +is thereafter indistinguishable from a new one. + +```ts +// src/server/modules/runs/legacy/adoptLegacyJob.ts +// Delete this file, its two call sites and the old-shape schema to drop compatibility. +export async function adoptLegacyJob(deps, handle: JobHandle, input: unknown): Promise; +``` + +Two call sites: the boot backfill, and the provider before invoking the handler when the input +carries no pointer. **The handler never learns the old shape exists.** + +## Decision records + +### D1. Our record is the source of truth + +**Context.** Every fact the plugin needs about a run lives in the runner, so each new need becomes a +new port member. `assignmentsOf` arrived that way days ago. + +- **Options considered:** keep reading the runner with optional capabilities, as #200 filed it · ask only `statesOf(handles)`, native to any queue · never read the runner at all + +**Decision.** (c). + +**Why the others lost.** (a) leaves the port growing with every future need, and leaves status +broken on Payload's default `deleteJobOnComplete: true`. (b) still needs an `unknown` answer for a +handle the runner never issued, and buys only repair after a lost report — a cure for a case the +queue's own retries already narrow. + +**Consequences.** `report` stops being a notification and becomes load-bearing for correctness; it +must be asserted in `assertTaskRunnerContract`. A handle no runner owns is never settled. + +### D2. One run per document — blocking, not merging + +**Context.** Two runs over one document lose each other's work, and it cannot be fixed at the write: +`collections/operations/utilities/update.js:284` builds the whole document with every locale, and +`@payloadcms/drizzle/upsertRow/index.js:233` writes a row per locale present. + +- **Options considered:** keep merging and keep asking the host for `enableConcurrencyControl` · let the plugin serialise per document, holding a backlog · refuse while the document's run is unsettled + +**Decision.** (c). + +**Why the others lost.** (a) costs `planEnqueue`, `alreadyCovered`, `covers` and `extendJob`, and +still leaves cases only the host's flag covers. (b) makes the plugin a scheduler and turns a stuck +row into a backlog that never drains — a silent failure with no escape hatch. + +**Consequences.** An editor adding a locale mid-run must cancel or wait; the re-queue is composed +from what is still owed, or cancelling costs money. A stuck row blocks its document until someone +cancels — visible, and the button already exists. `serialisesPerDocument` is not needed. + +### D3. The table stores facts, not a status + +**Context.** The panel shows `pending | running | completed | failed`; those are derived, not +observed. + +- **Options considered:** one `status` column · a timestamp per terminal event · `settledAt` plus `outcome` + +**Decision.** (c). + +**Why the others lost.** (a) freezes a vocabulary that a migration would have to change, and gives +no durations. (b) makes "in flight" three null checks rather than one indexed predicate, and lets a +row express "delivered and failed" at once. + +**Consequences.** `settledAt` is the liveness predicate, matching the shape the code already uses +(`completedAt: { exists: false }`). Status is derived in one place. + +### D4. The job input becomes a pointer + +**Context.** What the queue stores for the Payload Jobs runner — `collection_slug`, `collection_id`, +`source_lng`, `targets` with filters, `publish_on_translation`, `requester_*` +(`PayloadJobsTaskRunner.ts:209-215`) — is the same set the table would hold. + +- **Options considered:** store the facts in both the job input and the table · carry `{ handle }` in the job and read the rest from the table + +**Decision.** (b). + +**Why (a) lost.** It stores the same facts twice in one database and keeps the stored run shape in +the runner, where `jobInput.schema`, `runTargets`, `normalizeJob` and the legacy readers already +live — the 468 lines this step is meant to remove. + +**Consequences.** A job in the Payload admin shows only a handle. A lost row makes a queued job +unrunnable, so a row with no `settledAt` must never be pruned. The handler reads the table; the +runner never does. + +### D5. The record write joins the caller's transaction and its failure fails the enqueue + +**Context.** Provenance writes best-effort and swallows. Here a lost record means the next request +sees no live run and queues a second one over the same document, which the host pays for twice. + +- **Options considered:** best-effort and swallowed, as provenance does · fatal, through a `TranslatorError` with `mustPropagate = true` + +**Decision.** (b). + +**Why (a) lost.** It trades a database hiccup for a double-charged translation that also loses work. + +**Consequences.** On the auto-translate path the editor's save fails — correct, and usually +unavoidable, since a failed write inside a transaction has already killed it. `PermissionCheckFailed` +is classified the same way, so this adds a member to an existing vocabulary rather than a new rule. + +## Build sequence + +Each step leaves the tree green and is reviewed on its own. + +1. **The collection** — schema, store, wiring, slug guard, `afterDelete` cascade. Nothing reads or + writes it yet. covers: R1 +2. **Writing** — `report` gains the scope, `TaskHandlerInput` gains `handle`, the stamps, the + sanitised error, the transaction rule. covers: R3, R6, R7 +3. **Reading** — the four routes move to the table; `assignmentsOf` and `findByCollection` stop + being called. covers: R2 +4. **Blocking replaces merging** — the surviving policy moves to `core/domain/runs/`, and refusal + gains its second meaning. covers: R4 +5. **The port narrows** — routes register by capability; four deprecations removed. covers: R5 +6. **The pointer input** — `adoptLegacyJob`, the boot backfill, `store/` shrinks. covers: R8 + +## Verification + +1. every route answers with `SyncTaskRunner`, which implements `enqueue` alone; +2. a runner without `stop` does not register `/cancel`, and its control is not drawn; +3. **with Payload's default `deleteJobOnComplete: true`, a completed translation still shows + "Completed"** — no current test covers the default; `apps/dev` and `bootTestPayload` both set it + to `false`; +4. a second request for a busy document is refused, and the refusal is told apart from a refusal by + rights; +5. a job queued before the upgrade runs after `adoptLegacyJob`, and runs even if the boot backfill + never did; +6. two requests naming the same locale produce one row, not two; +7. a failed row write fails the enqueue, and on the auto-translate path fails the editor's save + rather than leaving a run unrecorded; +8. a `filters` value the parser rejects translates nothing for that locale, rather than everything; +9. status reads issue a bounded, index-backed query — assert the `where`, not the timing; +10. an error stored for a failed run contains no provider detail. + +## Deliberately not done + +- **No "abandoned" state.** A row stuck in "running" after a lost final report stays there. Deriving + abandonment from age would need a second timeout beside the runner's `staleJobTimeoutMs`, and two + unrelated numbers would contradict each other on one row. The escape hatch is the cancel button. +- **No retention inside the plugin.** See #122. +- **`/run/:id` is not replaced by re-enqueue.** Re-enqueueing queues work; it does not run it, which + on a cron-less host means nothing happens. +- **A handle no runner owns is never settled.** Switching runners leaves rows nothing will close. + `statesOf` would have detected it and was dropped on purpose. One line in the README. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-26-the-run-record-collection.task.md b/packages/payload-plugin-translator/docs/plans/2026-10-26-the-run-record-collection.task.md new file mode 100644 index 000000000..668877d6f --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-26-the-run-record-collection.task.md @@ -0,0 +1,184 @@ +Build step: ./2026-10-26-the-plugin-keeps-its-own-record.design.md #1 + +# Step 1 — the run record collection + +## Requirements / Task restatement + +Register `translator-runs`, the collection that will hold the plugin's own record of translation +runs, together with the store that reads and writes it, the guard that refuses a colliding slug, and +the cascade that removes a document's rows when the document is deleted. + +**Nothing reads or writes it in this step.** Writing is step 2, reading is step 3. This is a pure +addition: the only change to existing code is the plugin registering three config modifiers. + +Covers R1 of the design. + +## What already exists — platform · installed · project · verdict + +- **Platform.** Payload 3.90.1 supports collection-level compound indexes, including `unique` + (`collections/config/types.d.ts:565-573`). Used for the upsert key rather than hand-rolled. +- **Installed.** Nothing new is added. +- **Project.** `src/server/modules/provenance/` is the one precedent for a plugin-owned collection, + and every piece of this step has a counterpart there: `Provenance.collection.ts`, + `Provenance.store.ts` (upsert by key with a race retry), `slugGuard.ts`, + `ProvenanceCleanup.hook.ts`, `Provenance.wiring.ts`. +- **Verdict.** Nothing new is designed. The shape is copied from provenance; the differences are + recorded below as decisions. + +## Decisions + +**D1 — registered directly, not as a `TranslationModule`.** +Chosen: `plugin.ts` adds the three config modifiers through the builder, beside `runnerConfigModifier`. +Lost: wrapping it in a module for symmetry with provenance. Provenance is a member of the translation +chain because it *participates* in a translation — it records a receipt. The run record does not: in +this step it contributes only config modifiers, and in step 2 the write happens inside `report` in +`wireTranslateRunner`, not in a chain member. A module would add a `setup`, a name and a +`refuseDuplicateNames` entry for three calls. + +**D2 — the slug is configurable: `runs?: { slug?: string }`.** +Chosen: an option mirroring `provenance?: boolean | { slug?: string }`, defaulting to +`translator-runs`. Lost: a fixed slug. The collection is **mandatory**, so a collision is a fatal +config-time error with no way out; a host that already owns `translator-runs` could not install the +plugin at all. Provenance carries the option for exactly this reason. Confirmed by the owner at the +gate. + +**D3 — no port in `core/` yet.** +Chosen: a concrete `PayloadRunRecordStore`. Lost: a port in `core/domain/runs/` mirroring +`ProvenanceStore`. Provenance has a port because `core` code consumes it; here the second caller +arrives in step 4, when the run policy moves into `core/`. Until then a port would be an abstraction +with one caller, which the project's own rules forbid. + +**D4 — honest field types, `checkbox` and `number`.** +Chosen: `publishOnTranslation` as `checkbox`, `attempts` as `number`. Lost: text for both, matching +`Provenance.collection.ts`'s self-imposed "only `text`/`date`". That rule is provenance's own choice, +not an adapter limit: the repository already uses `checkbox` 17 times and `number` 26 times across +the same three adapters. + +**New surface.** One published option, `runs?: { slug?: string }`. The shape check fired on two +triggers — a field added to a published type, and the field being optional. Two shapes were weighed: +the nested `runs?: { slug?: string }` and a flat `runsSlug?: string`. The nested one was chosen: it +matches the neighbouring `provenance` option, and a second setting for this collection costs a key +rather than another top-level field. + +**Written contract.** `upsert` owes callers something its signature cannot state: it is idempotent on +`(handle, targetLng)`, and it survives losing the unique-index race by finding the winner and +updating it. That is what AC5 and AC6 pin. + +## Acceptance Criteria + +| AC | Covers | Criterion | How it is checked | Passes when | +|---|---|---|---|---| +| AC1 | R1 | Booting the plugin registers a collection whose slug is `translator-runs` | `bunx vitest run src/server/modules/runs/__tests__/registration.test.ts -t "registers"` | exit 0, the case runs | +| AC2 | R1 | `runs: { slug: "x" }` registers it under `x` instead | same file, `-t "honours the configured slug"` | exit 0, the case runs | +| AC3 | R1 | The collection refuses read, create, update and delete, and is hidden in the admin | same file, `-t "closed to everyone"` | exit 0, the case runs | +| AC4 | R1 | A slug already taken by a host collection throws `TranslatorConfigError` at config time, naming the option | same file, `-t "collides"` | exit 0, the case runs | +| AC5 | R1 | Two upserts for one `(handle, targetLng)` leave one row, with the second's values | `apps/dev`: `bunx vitest run -c vitest.integration.config.ts src/integration/translator/run-record.int.test.ts -t "once"` | exit 0, the case runs | +| AC6 | R1 | When `create` throws because another writer won the unique index, `upsert` finds the winner and updates it | `bunx vitest run src/server/modules/runs/__tests__/store.test.ts -t "race"` | exit 0, the case runs | +| AC7 | R1 | Deleting a managed document deletes that document's run rows | `apps/dev` integration, `-t "deleting the document"` | exit 0, the case runs | +| AC8 | R1 | Deleting a document of a collection the plugin does not manage deletes no run rows | same file, `-t "unmanaged"` | exit 0, the case runs | +| AC9 | — | No new type errors | `bun run check-types` | 0 errors | +| AC10 | — | The package suite stays green and grows | `bunx vitest run` | ≥ 2620 passing, 0 failing | +| AC11 | — | No new lint findings | `bunx oxlint .` | 43 warnings, 0 errors | +| AC12 | — | Integration suites unchanged | `apps/dev`: `bun run test:integration` | ≥ 229 passing, 0 failing | +| AC13 | R1 | A fresh SQLite database reaches the new table through the dev app's migrations, not only through `push` | `apps/dev`: `DB_ADAPTER=sqlite … payload migrate` against an empty file, then the table exists | the migration applies and the table is present | +| AC14 | R1 | The same migration applies on PostgreSQL | `apps/dev`: `DB_ADAPTER=postgres bun run payload migrate` | applies cleanly — **needs the Docker service**; `not-verified` if it cannot be started | + +## Pre-flight + +Run against the untouched tree on 2026-10-26. + +| AC | Command | Result | Kind | +|---|---|---|---| +| AC1 | a throwaway probe booting the plugin and listing `config.collections` | **FAIL** — `expected [ 'posts' ] to include 'translator-runs'` | change | +| AC2–AC8, AC13 | — | not run: their subject does not exist yet; AC1's probe stands for the whole group, since all of them presuppose the collection | change | +| AC9 | `bun run check-types` | **PASS** — 0 errors | invariant | +| AC10 | `bunx vitest run` | **PASS** — 2620 passing | invariant | +| AC11 | `bunx oxlint .` | **PASS** — 43 warnings, 0 errors | invariant | +| AC12 | `bun run test:integration` | **PASS** — 229 passing, 8 skipped (measured earlier today) | invariant | +| AC14 | both suites, on the containers started for this run | **PASS** — Postgres 235 passing / 2 skipped; Mongo 228 passing / 9 skipped | invariant | + +### Baselines captured before any edit + +| Adapter | Passing | Skipped | +|---|---|---| +| SQLite (default) | 229 | 8 | +| PostgreSQL | 235 | 2 | +| MongoDB | 228 | 9 | + +The counts differ by adapter because suites skip themselves where a capability is absent; each is its +own baseline, and Phase 5 compares like with like. + +## Risk notes + +- **The schema is the expensive part.** Code here is small and isolated, but a field wrong in step 1 + costs a second migration later. The field list comes from the design's "The table" section, which + was derived from what `alreadyCovered` and `pickHost` actually compare. +- **A mandatory collection is a schema change for every host**, including those who never enabled + provenance. That is why D2 gives them a slug to move to. +- **The cascade must not roll back the host's delete.** Provenance deletes outside the caller's + transaction for exactly this reason (`Provenance.store.ts:141-150`); the same rule applies here, + and it is the opposite of the write rule that arrives in step 2. + +## Human choices + +- 2026-10-26 — *"В этом этапе"*: the `apps/dev` migration lands in this step rather than once at the + end, so PostgreSQL and MongoDB are exercised before the shape is built on. +- 2026-10-26 — *"Настраиваемый"*: the collection slug is configurable, accepting one new published + option. + +## Review log + +### Verification — 2026-10-26 + +| AC | Result | +|---|---| +| AC1–AC4 | PASS — `src/server/modules/runs/__tests__/registration.test.ts`, 8 checks | +| AC5 | PASS, **restated** — see below | +| AC6 | PASS — `__tests__/store.test.ts`, proved by mutation M4 (lost race) | +| AC7, AC8 | PASS — `apps/dev` `run-record.int.test.ts`, on all three adapters | +| AC9 | PASS — `check-types` 0 errors; `turbo check-types` 9/9; `turbo build` (real `tsc`) clean | +| AC10 | PASS — 2646 passing / 0 failing (baseline 2620) | +| AC11 | PASS — 43 warnings, 0 errors | +| AC12 | PASS — SQLite 235/8, PostgreSQL 241/2, MongoDB 233/10 | +| AC13 | PASS — fresh SQLite file → `payload migrate` → `translator_runs` with `handle_targetLng_idx` UNIQUE | +| AC14 | **not applicable as written** — see below | + +Mutations, all restored: the store's five (always-create · handle alone · swallow a non-race +failure · drop the caller's transaction · join it on the cascade), the registration's five (never +add the collection · ignore the configured slug · drop the collision guard · hook every collection · +open the record for reading), and two across the real database (drop the unique index · never +register the cascade). Each reddened its own checks and nothing else. + +### Deviations from the contract + +- **AC5 restated.** The criterion asked for two *upserts*, but `PayloadRunRecordStore` is internal to + the package and `apps/dev` consumes only the published surface, so the integration spec cannot + drive it. The two halves are split instead: the database refusing a second `(handle, targetLng)` + is the integration check, and `upsert` choosing update-over-create is AC6's unit check. Together + they cover what AC5 asked; neither alone does. +- **AC14 does not apply.** The dev app's committed migrations import `@payloadcms/db-sqlite` and + carry SQLite DDL, so there is no "same migration" to run on PostgreSQL — that adapter reaches the + schema through `push`. Verified that way instead: the full suite on PostgreSQL, 241 passing. +- **The generated migration was narrowed by hand.** `payload migrate:create` produced 16 `CREATE + TABLE` statements, of which one was `translator_runs`; the other 15 are pre-existing drift between + the dev app's config and its committed migrations (playground version tables). Shipping them under + a name that says `create_translator_runs` would have hidden an unrelated schema catch-up inside + this step, so the file keeps only the run record's statements. The adapter's `.json` snapshot was + dropped with them, because a snapshot describing tables the migrations do not create is worse than + none. **The drift itself is untouched and still owed.** + +### Found along the way + +- **MongoDB enforces the key only once the background index build finishes.** Measured on a fresh + database: immediately after the collection's first write only `_id` and `createdAt` are indexed; + the compound unique key appears about three seconds later. A duplicate written in that window is + accepted. The integration check is skipped on MongoDB for that reason (`KEY_IS_ENFORCED_AT_ONCE`), + and the design's "on the unique-index race, re-find and update" has no race to lose there — on a + brand-new database two concurrent writers can both create a row. Narrow (one collection, once per + database) but real, and it belongs in step 2's write rule rather than here. +- **Three provenance checks were counting the wrong thing.** `plugin.test.ts` asserted provenance's + cleanup hook by the length of `afterDelete`, which a second injector breaks. They now count by the + marker, matching the file's own `markedAfterChange`. +- **`bootTestPayload` had no unmanaged collection.** Every collection it registered was also handed + to the plugin, so "a collection the plugin does not translate" was unobservable. Added + `unmanagedCollections`, which AC8 needs. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-27-blocking-replaces-merging.task.md b/packages/payload-plugin-translator/docs/plans/2026-10-27-blocking-replaces-merging.task.md new file mode 100644 index 000000000..105849955 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-27-blocking-replaces-merging.task.md @@ -0,0 +1,238 @@ +Build step: ./2026-10-26-the-plugin-keeps-its-own-record.design.md #4 + +# Step 4 — blocking replaces merging + +## Requirements / Task restatement + +Today a second request for a document already being translated is *merged* into the live run: the +runner picks a host job, appends the locales it lacks, and reports the rest as already covered. That +machinery is `planEnqueue`, `pickHost`, `alreadyCovered`, `covers` and `extendJob`, and it still +leaves cases only the host's `jobs.enableConcurrencyControl` covers. + +This step replaces all of it with a refusal: **while a document has an unsettled run, a new request +for that document is turned away**, and the turning-away is told apart from a refusal by rights. The +decision is the plugin's, taken above the runner, so both ways in — the HTTP route and the +auto-translate hook — obey it. + +Covers R4 of the design. + +## What already exists — platform · installed · project · verdict + +- **Platform.** Nothing new. The plugin stops reading `jobs.enableConcurrencyControl`: with blocking + there is no second run for the flag to serialise. +- **Installed.** Nothing new is added. +- **Project.** Step 3 gave the store the read this needs in all but name — + `findLiveInCollection(slug)` — and the enqueue route already splits its work two ways and already + branches on `select_all`. `authorisedWork` is the shape the new split copies: distinct document + ids out, tasks in. +- **Verdict.** Nothing new is designed. One concept is named: whether a document is free to take new + work. + +## Decisions + +**D1 — the policy is pure and lives in `core/domain/runs/`.** +Chosen: `admitWork({ work, inFlight })` over plain shapes, with the Payload-side read in the store +and the orchestration in the two callers. Lost: deciding inside `PayloadJobsTaskRunner`, where the +merge logic lives today. That is where it has always been, and it is why the rule is unavailable to +the sync runner and to any runner a host writes. `core/` cannot import Payload (package +`CLAUDE.md`), which is the point: the rule structurally cannot reach back into job rows. + +**D2 — "busy" is a field of its own, never folded into `refused`.** +Chosen: `POST /enqueue` answers `{ queued, refused?, busy?, assignments? }`. Lost: one list. The two +mean opposite things to the person reading the panel — *you may not* versus *not yet* — and the +second resolves by itself. A client that showed them together would tell an editor they lack rights +they have. + +**D3 — blocking reads liveness, not history.** +A document whose runs have all settled takes new work, whatever those runs did. The predicate is the +one the record already has an index for, `settledAt` absent. + +**D4 — `select_all` asks about the collection, the id list asks about its documents.** +Chosen: the handler's existing `select_all` branch picks the read — +`findLiveInCollection(slug)` for the whole collection, `findLiveForDocuments(slug, ids)` otherwise. +Lost: one read for both. A `where … in [every id in the collection]` is the query `select_all` would +produce, and SQLite's bound on host parameters makes that a real failure rather than a slow one. + +**D5 — auto-translate skips a busy document, silently to the editor.** +Chosen: the hook drops the work and logs. Lost: queueing anyway, and failing the save. Confirmed by +the owner: "если занято и уже стоит перевода, значит отказ. Поле просто останется помеченым как +устаревшее, не критично" — the staleness marker survives, so the document is visibly out of date and +the next save or a manual run picks it up. + +**D6 — the host's `enableConcurrencyControl` is still honoured, never set.** +The workflow keeps its `concurrency` block when the host has turned the flag on. The plugin simply +stops *reading* it to decide anything: blocking makes a second run for one document impossible by +construction, so there is nothing left for the flag to serialise on the plugin's behalf. + +**New surface.** One field on a published response, `busy`. The shape check fired on "a field is +added to a type that is published": weighed against reusing `refused` with a reason code per entry. +The separate field wins on the count that matters — a client that ignores `busy` behaves exactly as +before, while a reason code changes the type of every existing entry. + +**Written contract.** `admitWork` owes callers two things its signature cannot state: a document with +**any** unsettled run is busy, however many locales that run covers; and `busy` lists each document +once, not once per locale. AC2 and AC7 pin them. + +## Acceptance Criteria + +| AC | Covers | Criterion | How it is checked | Passes when | +|---|---|---|---|---| +| AC1 | R4 | Work for a document with an unsettled run is not queued | `bunx vitest run src/core/domain/runs/__tests__/admitWork.test.ts` | exit 0, the cases run | +| AC2 | R4 | A busy document is named once, whatever the run covers | same file, `-t "once"` | exit 0, the case runs | +| AC3 | R4 | A free document in the same request is still queued | same file, `-t "alongside"` | exit 0, the case runs | +| AC4 | R4 | A document whose runs have all settled is free again | same file, `-t "settled"` | exit 0, the case runs | +| AC5 | R4 | `POST /enqueue` names the busy documents apart from the ones it refused by rights | `bunx vitest run src/server/features/enqueue-translation/` | exit 0, the cases run | +| AC6 | R4 | `select_all` asks the record about the collection, an id list asks about those documents | same file, `-t "select_all"` | exit 0, the case runs | +| AC7 | R4 | Auto-translate queues nothing for a busy document, and the editor's save still succeeds | `bunx vitest run src/server/modules/auto-translate/` | exit 0, the cases run | +| AC8 | R4 | The merge policy is gone from the package | `grep -rE "planEnqueue\|alreadyCovered\|extendJob" src` | no match outside the deprecations log | +| AC9 | R4 | The plugin reads `enableConcurrencyControl` nowhere but where it hands it to Payload | `grep -rn "enableConcurrencyControl" src` | the only match configures the workflow | +| AC10 | R4 | On a real database a second request for a busy document is refused, and the first run still finishes | `apps/dev`: `bunx vitest run -c vitest.integration.config.ts src/integration/translator/one-run-per-document.int.test.ts` | exit 0, the cases run | +| AC11 | — | No new type errors | `bun run check-types` | 0 errors | +| AC12 | — | The package suite stays green | `bunx vitest run` | 0 failing | +| AC13 | — | No new lint findings | `bunx oxlint .` | ≤ 37 warnings, 0 errors | +| AC14 | — | The published package still builds under the real `tsc` | repo root: `bunx turbo run build --filter='./packages/payload-plugin-translator'` | exit 0 | +| AC15 | — | Integration suites stay green on all three adapters | `apps/dev`: `bun run test:integration` ×3 | 0 failing | + +The package suite's count is expected to **fall**: this step deletes `planEnqueue`, `alreadyCovered` +and `covers` together with their specs, which is most of the merge machinery's coverage. AC12 asks +for zero failures rather than a number, and the review log records the arithmetic. + +## Pre-flight + +Run against the tree as step 3 left it, on 2026-10-27. + +| AC | Command | Result | Kind | +|---|---|---|---| +| AC1–AC7, AC10 | — | not run: nothing blocks, so every criterion fails by construction | change | +| AC8 | `grep -rE "planEnqueue\|alreadyCovered\|extendJob" src` | **FAIL** — 14 matches across the runner and its specs | change | +| AC9 | `grep -rn "enableConcurrencyControl" src` | **FAIL** — read in `PayloadJobsTaskRunner.enqueue` to feed `planEnqueue`, besides the workflow config | change | +| AC11 | `bun run check-types` | **PASS** — 0 errors | invariant | +| AC12 | `bunx vitest run` | **PASS** — 216 files, 2694 passing | invariant | +| AC13 | `bunx oxlint .` | **PASS** — 37 warnings, 0 errors | invariant | +| AC14 | `bunx turbo run build --filter=…` | **PASS** — measured at the end of step 3 | invariant | +| AC15 | `bun run test:integration` ×3 | **PASS** — SQLite 246 / 10 skipped, PostgreSQL 252 / 4, MongoDB 244 / 12 | invariant | + +## Risk notes + +- **Integration specs built on merging will fail, and that is the point.** `job-extend`, + `locale-append`, `exclusive-queue` and the two `queued-before-any-ending` specs all describe a + second request joining a live run. Each is read on its own: the ones that pin *merging* go with + the mechanism, the ones that pin something else through merging are re-aimed. None is deleted + quietly; the review log names every one. +- **The cost of being wrong is an editor who cannot translate.** A row stuck unsettled blocks its + document until someone cancels. The design accepts this and names the escape hatch (the cancel + button), but it means the liveness predicate has to be exactly right — which is why AC4 exists + separately from AC1. +- **Two requests racing on one free document can both be admitted.** The read and the write are not + one transaction, and the record's unique key is per `(handle, targetLng)`, not per document. The + window is the span of one enqueue. Narrowing it further needs a lock the design did not ask for; + recorded rather than built. + +## Review log + +### Verification + +| AC | Command | Result | +|---|---|---| +| AC1–AC4 | `bunx vitest run src/core/domain/runs/` | **PASS** — 8 cases | +| AC5, AC6 | `bunx vitest run src/server/features/enqueue-translation/` | **PASS** — 7 new cases among them | +| AC7 | `bunx vitest run src/server/modules/auto-translate/` | **PASS** — 46 cases, 4 new | +| AC8 | `grep -rE "planEnqueue\|alreadyCovered\|extendJob" src docs` | **PASS** — no match. Three prose references survived the code: two test comments and one line in the deprecations log, all repaired | +| AC9 | `grep -rn "enableConcurrencyControl" src` | **PASS** — one match, the workflow's own `concurrency` block | +| AC10 | `apps/dev`: `… one-run-per-document.int.test.ts` | **PASS** — 5 cases | +| AC11 | `bun run check-types` | **PASS** — 0 errors | +| AC12 | `bunx vitest run` | **PASS** — 2645 passing, 0 failing (from 2694) | +| AC13 | `bunx oxlint .` | **PASS** — 37 warnings, 0 errors | +| AC14 | `bunx turbo run build --filter=…` | **PASS**; `bunx turbo run check-types` 9/9 | +| AC15 | `bun run test:integration` ×3 | **PASS** — SQLite 242 / 10 skipped, PostgreSQL 248 / 4, MongoDB 240 / 12 (from 246 / 252 / 244) | + +**The arithmetic the contract asked for.** The package suite lost 72 checks and gained 23. The +Payload Jobs runner alone is **1090 lines lighter against 52 added**. What went: `planEnqueue`, +`pickHost`, `alreadyCovered`, `covers`, `extendJob` and their four spec files, plus eight cases in +`PayloadJobsTaskRunner.test.ts` that described extending a live job. What arrived: `admitWork` with +eight cases, seven on the enqueue route, four on the auto-translate hook, two on the store and two +pinning that the runner no longer looks at the job table before queuing. + +The integration suites lost three files — `job-extend`, `locale-append` and `legacy-row-upgrade` — +and one case of `exclusive-queue`. Each is named under Deviations. + +### Mutations + +Fifteen mutations, each applied to the implementation alone, the affected specs run, and the file +restored from a copy with the restore verified by comparison. + +| | What was broken | Went red | +|---|---|---| +| P1 | a busy document is admitted after all | 8 checks, across the policy and the route | +| P2 | a busy document is named once per locale | names a busy document once | +| P3 | the collection is dropped from the address | keeps two collections apart | +| P4 | the handler asks about documents the caller may not change | asks only about the documents the caller may change | +| P5 | busy and refused share one field again | 3 checks | +| P6 | the queued count stops matching what was admitted | 2 checks | +| P7 | `select_all` asks about documents rather than the collection | asks about the whole collection | +| P8 | auto-translate queues over a run already under way | 2 checks | +| P9b | auto-translate throws instead of skipping | leaves the editor's save alone, even inside their transaction | +| P10b | the runner looks at the job table before queuing | reads no job at all before queuing one | +| P11 | the live read narrows by document but not by settlement | asks by both halves of the key and the absence of a settlement | +| P12 | an empty document list still asks the database | asks nothing at all when no document was named | + +**One mutation did not bite, and it was a real finding.** P9 made the auto-translate hook *throw* +on a busy document, and nothing went red: the check ran without a transaction in scope, where +`swallowOrThrow` swallows and the save succeeds either way. The guarantee that matters is the one +inside the editor's transaction, where a throw rolls their save back. The check now runs with a +`transactionID` and also asserts nothing was logged as an error; P9b then reddened it. + +### The review passes + +**Complexity.** Scenario: *the rule for whether a document may take new work changes* — which is +what this step is. Before, that rule lived in five units inside the Payload Jobs runner +(`planEnqueue`, `pickHost`, `alreadyCovered`, `covers`, `extendJob`), every one of them reading the +shape of a stored job row, and it was unavailable to the sync runner or to any runner a host writes. +After, it is one pure function with two callers. CHANGE 5 → 1. READ falls with it: the rule no +longer requires knowing how a job row stores its locale list. STATE: `EnqueuePlan` admitted five +interdependent fields (`host`, `append`, `queue`, `covered`, `coveredBy`, where `host: null` with a +non-null `coveredBy` meant something specific); `Admission` admits two, and every state it admits is +one the system produces. + +**Abstraction.** One finding, applied: the policy's shape was called `DocumentRef`, and +`core/domain/translation-providers/` already exports a `DocumentRef` — same layer, same name, +*different shape* (`{ collection, id }` against `{ collectionSlug, collectionId }`). Renamed to +`OnADocument`, which is what `shared/access/authorised.ts` already calls that exact shape, so the +package now has one name for it. The alias `RunInFlight` went with it: it was `DocumentRef` under +another name, and the parameter's own docblock says what the list is. The barrel narrowed to +`admitWork` alone — nothing outside the module names the types. + +**Comments.** Every block in the diff was judged. Three prose references to the deleted mechanism +were found outside the code AC8 greps for and repaired: two test comments explaining why a job row +can carry two shapes (rewritten to name the release rather than the function), and one `Code refs` +line in `docs/DEPRECATIONS.md` pointing at `planEnqueue.ts`, removed. Everything added in this step +says why rather than what, and the heaviest of them — `admitWork`'s docblock — carries the Payload +behaviour that makes blocking necessary rather than merely tidy. + +### Deviations from the contract + +1. **Three integration files were deleted, not re-aimed.** `job-extend.int.test.ts` (4 cases) and + `locale-append.int.test.ts` (1 case) describe appending a locale to a live run, which no longer + exists. `legacy-row-upgrade.int.test.ts` (3 cases) describes appending to a *pre-1.0* row; its + third case, which looked like it was about the status route, passes now for a reason that has + nothing to do with the legacy row — the panel reads the run record, which this step does not + touch — so keeping it would have been keeping a check that no longer asks its own question. +2. **One case of `exclusive-queue.int.test.ts` was deleted.** "Holds a second job for the same + document, then runs it and loses nothing" cannot arise: there is never a second job for one + document to hold. The file's other case stays, and its header now says why the first one went. +3. **`RequestShape` moved to `store/types.ts`.** It lived in `planEnqueue.ts` and outlived it; it + describes the header a job row stores, which is what `store/` is for. + +### Findings carried forward + +- **Two requests racing on one free document can both be admitted.** Recorded in the contract before + the code existed: the read and the write are not one transaction, and the record's unique key is + per `(handle, targetLng)`, not per document. The window is one enqueue long. Closing it needs a + lock, which the design did not ask for. +- **Re-queueing after a cancel still asks for every locale.** The design says the re-queue should be + composed from what is still owed, so a locale already delivered is not paid for twice. That is the + admin composing a request from the status panel — client work, and #48's — so nothing here does + it. The server is ready for it: the panel already answers which locales are delivered. +- **The deprecation `jobs-task-shape` lost a code reference but not its substance.** `ownJobs()` and + `normalizeJobLocales` still carry the fallback for jobs queued as a bare task; step 6 is where + that is decided. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-27-reading-the-run-record.task.md b/packages/payload-plugin-translator/docs/plans/2026-10-27-reading-the-run-record.task.md new file mode 100644 index 000000000..8fd847bdd --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-27-reading-the-run-record.task.md @@ -0,0 +1,223 @@ +Build step: ./2026-10-26-the-plugin-keeps-its-own-record.design.md #3 + +# Step 3 — reading the run record + +## Requirements / Task restatement + +Step 2 made the plugin write its own record of every run. This step makes it *read* it: the five +routes that today ask the runner what it has — `/cancel`, `/run/:id`, `/cancel/:collection_slug`, +the document status panel and the collection status panel — ask the table instead. + +`assignmentsOf` and `findByCollection` keep their place on the port; nothing calls them after this +step, and step 5 removes them. The wire shapes the admin reads do not change. + +Covers R2 of the design, and finishes what R3 began — the panels now answer from a table Payload's +`deleteJobOnComplete` cannot empty. + +## What already exists — platform · installed · project · verdict + +- **Platform.** Payload's `find` takes `where`, `limit`, `sort` and `pagination`. Both reads this + step adds are keyed by an index the collection already declares — `(collectionSlug, collectionId)` + and `handle` — so neither needs a new one. +- **Installed.** Nothing new is added. +- **Project.** The five handlers already share two helpers that stay: `visibleIds` for the host's + own read rule, and `authorisedHandles` for the write rule on a document. `PayloadRunRecordStore` + already answers `find` and `findByDocument` — written in step 1 for exactly these callers. +- **Verdict.** Nothing new is designed. One concept is named for the first time: the status a row + *implies*, which today is a column on someone else's job row. + +## Decisions + +**D1 — the collection panel keeps answering with rows, not three counts.** +This deviates from the plan sketch, which proposed three `count` queries. It cannot be done: the +panel's numbers are filtered through `visibleIds`, the host's own read rule on the *content* +collection, and a count has no ids left to filter. Counting server-side would show a user documents +they may not read. The rows stay, bounded by an explicit `limit` and sorted, which is what the +"bounded and index-backed" requirement was actually asking for. + +**D2 — a cancelled row is not shown by either panel.** +Chosen: both reads drop rows whose outcome is `cancelled`. Lost: showing them. Today a cancelled run +is *deleted* from `payload-jobs`, so the panel has never shown one; keeping the display identical is +what makes this step a move rather than a behaviour change. The row itself stays, so the history is +there when the admin gains a word for it (#48). A locale cancelled while its work was in flight +still settles `delivered` and still shows — the later settlement wins, as step 2 established. + +**D3 — status is derived in one place, from the row.** +`runStatus.ts` in the runs module: `outcome` set decides, else `startedAt` means running, else +pending. Lost: deriving it inside each handler. Two readers already exist, and the design's whole +point (D3) is that the vocabulary is derived rather than stored. + +**D4 — three new store reads, each named for its question.** +`findByHandles(handles)` for the two routes that are handed handles; `findLiveInCollection(slug)` +and `findRecentlyFailedInCollection(slug, since)` for the two that ask about a collection. Lost: one +general `find(where)`, which would move the query shapes back out into the handlers and leave nothing +checkable in one place; and lost one combined collection read with an `or`, because the two halves +are asked for different reasons — work in flight is kept however old it is, a failure only inside the +window — and separating them makes each query say so. + +**D5 — the handlers take a store factory; the two that stop or start work keep the runner.** +`/cancel` and `/run/:id` still call `cancel` and `run`, so they hold both. The two panels and +`/cancel/:collection_slug` hold the store and, for the last of those, the runner only to cancel. + +**New surface.** Nothing is added to `src/index.ts`. `ConfigTimeContext` gains +`runRecordStoreFactory`, which `plugin.ts` fills from `configureRunRecord`. The shape check fired on +"a field is added to a type implemented outside this package" — it is not: `ConfigTimeContext` is +internal, built only by `plugin.ts`, and no published type mentions it. + +**Written contract.** `findByHandles` owes callers something its signature cannot state: a handle no +row names is **omitted, never guessed** — the same rule `assignmentsOf` carries today, and the reason +is the same, an empty answer reads as "nothing left to cancel". AC11 pins it. + +## Acceptance Criteria + +| AC | Covers | Criterion | How it is checked | Passes when | +|---|---|---|---|---| +| AC1 | R2 | The document panel answers from the table, asking the runner nothing | `bunx vitest run src/server/features/get-document-status/handler.test.ts` | exit 0, the cases run | +| AC2 | R2 | It shows one entry per target locale, the newest run for that locale | same file, `-t "per target locale"` | exit 0, the case runs | +| AC3 | R2 | A locale whose only row is cancelled is not shown | same file, `-t "cancelled"` | exit 0, the case runs | +| AC4 | R7 | The stored failure text reaches the client as stored, not sanitised a second time | same file, `-t "as it was stored"` | exit 0, the case runs | +| AC5 | R2 | The collection panel lists unsettled runs and failures inside the window, and nothing settled before it | `bunx vitest run src/server/features/get-collection-status/handler.test.ts` | exit 0, the cases run | +| AC6 | R2 | It still hides documents the caller may not read | same file, `-t "may not read"` | exit 0, the case runs | +| AC7 | R2 | `/cancel` places each handle through the table, and refuses the ones the rule refuses | `bunx vitest run src/server/features/cancel/handler.test.ts` | exit 0, the cases run | +| AC8 | R2 | `/run/:id` places its handle through the table and refuses one with no recorded requester | `bunx vitest run src/server/features/run-translation/handler.test.ts` | exit 0, the cases run | +| AC9 | R2 | `/cancel/:collection_slug` leaves a run alone while one of its locales is running | `bunx vitest run src/server/features/cancel-by-collection/handler.test.ts` | exit 0, the cases run | +| AC10 | R2 | Both collection reads are bounded and keyed by an index the collection declares | `bunx vitest run src/server/modules/runs/__tests__/store.test.ts -t "bounded"` | exit 0, the cases run | +| AC11 | R2 | A handle no row names is omitted from the answer, never guessed | `… store.test.ts -t "no row names"` | exit 0, the case runs | +| AC12 | R2 | On a real database the panels answer correctly after a run, with the queue emptied | `apps/dev`: `bunx vitest run -c vitest.integration.config.ts src/integration/translator/run-record-read.int.test.ts` | exit 0, the cases run | +| AC13 | — | No new type errors | `bun run check-types` | 0 errors | +| AC14 | — | The package suite stays green and grows | `bunx vitest run` | ≥ 2673 passing, 0 failing | +| AC15 | — | No new lint findings | `bunx oxlint .` | 43 warnings, 0 errors | +| AC16 | — | The published package still builds under the real `tsc` | repo root: `bunx turbo run build --filter='./packages/payload-plugin-translator'` | exit 0 | +| AC17 | — | Integration suites stay green on all three adapters | `apps/dev`: `bun run test:integration` ×3 | ≥ 241 / 247 / 239 passing, 0 failing | +| AC18 | R2 | A run between attempts reads as work in flight, not as a failure to act on | `apps/dev`: `… panel-shows-what-is-happening.int.test.ts -t "retrying"` | exit 0, the case runs | + +## Pre-flight + +Run against the tree as step 2 left it, on 2026-10-27. + +| AC | Command | Result | Kind | +|---|---|---|---| +| AC1–AC12 | — | not run: nothing reads the table, so every criterion fails by construction. The five handlers' existing suites pass today against the runner and are the before-state AC14 counts | change | +| AC13 | `bun run check-types` | **PASS** — 0 errors | invariant | +| AC14 | `bunx vitest run` | **PASS** — 214 files, 2673 passing | invariant | +| AC15 | `bunx oxlint .` | **PASS** — 43 warnings, 0 errors | invariant | +| AC16 | `bunx turbo run build --filter=…` | **PASS** — measured at the end of step 2 | invariant | +| AC17 | `bun run test:integration` ×3 | **PASS** — SQLite 241 / 8 skipped, PostgreSQL 247 / 2, MongoDB 239 / 10 | invariant | + +## Risk notes + +- **The sync runner has no unsettled rows, ever.** It settles every locale inside `enqueue`, so the + collection panel under `createSyncRunner` shows failures and nothing else. That is already true + today and is not this step's to fix; it is the reason the integration spec for the panels uses the + Payload Jobs runner with `autoRun: false`. +- **Double sanitising.** `toClientErrorMessage` must not run on the stored `error`: step 2 already + ran it, and a second pass collapses a reason's own copy to the generic text because the + re-authored copy carries no marker. AC4 is the check that holds this. +- **Rows accumulate where jobs used to be deleted.** The panels now read a table nobody prunes, so a + query that was bounded by "live jobs" must be bounded by `limit` instead. AC10 is that check, and + the window on failures is what keeps the collection read from growing without end. + +## Review log + +### Verification + +| AC | Command | Result | +|---|---|---| +| AC1–AC4 | `bunx vitest run src/server/features/get-document-status/` | **PASS** — 13 handler cases, 4 model cases, 3 route cases | +| AC5, AC6 | `bunx vitest run src/server/features/get-collection-status/` | **PASS** — 8 handler cases | +| AC7 | `bunx vitest run src/server/features/cancel/` | **PASS** — 10 + 6 cases | +| AC8 | `bunx vitest run src/server/features/run-translation/` | **PASS** — 8 + 4 cases | +| AC9 | `bunx vitest run src/server/features/cancel-by-collection/` | **PASS** — 9 + 4 cases | +| AC10, AC11 | `bunx vitest run src/server/modules/runs/__tests__/store.test.ts` | **PASS** — 20 cases | +| AC12 | `apps/dev`: `… run-record-read.int.test.ts` | **PASS** — 7 cases | +| AC13 | `bun run check-types` | **PASS** — 0 errors | +| AC14 | `bunx vitest run` | **PASS** — 216 files, 2694 passing (from 2673) | +| AC15 | `bunx oxlint .` | **PASS** — **37** warnings, 0 errors (from 43; see below) | +| AC16 | `bunx turbo run build --filter=…` | **PASS**; `bunx turbo run check-types` 9/9 | +| AC17 | `bun run test:integration` ×3 | **PASS** — SQLite 246 / 10 skipped, PostgreSQL 252 / 4, MongoDB 244 / 12 (from 241 / 247 / 239, 8 / 2 / 10 skipped) | +| AC18 | `apps/dev`: `… -t "retrying"` | **PASS** | + +The lint count **fell** rather than held. Four handlers declared their dependencies as constructor +parameter properties, which the linter has always objected to; adding a second dependency to each +would have added four more warnings, so all four were converted to explicit class properties — +the style `GetCollectionStatusHandler` already used. Six warnings went with them. + +### Mutations + +Eighteen mutations, each applied to the implementation alone, the affected specs run, and the file +restored from a copy with the restore verified by comparison. + +| | What was broken | Went red | +|---|---|---| +| N1 | a cancelled row gets a word after all | 4 checks, across the derivation and the document panel | +| N2b | the stamp is read before the outcome | reads the outcome over the stamp | +| N3 | running and pending are swapped | 3 derivation checks | +| N4b | a run is named once per locale instead of once per run | answers once per run · names each run once | +| N5 | half a requester is reported as a whole one | names nobody when half the requester is missing | +| N6 | the collection read stops narrowing by settlement | asks for the work in flight by the absence of a settlement | +| N7 | the failure window is dropped | asks for recent failures by outcome and moment | +| N8 | a read loses its bound | bounds every read | +| N9 | an empty handle list still asks the database | asks nothing at all when no handle was named | +| N10 | the document panel stops dropping rows it has no word for | 6 checks | +| N11 | the newest run for a locale stops winning | keeps the run queued most recently · keeps one entry per locale | +| N12 | the stored failure text is cleaned a second time | hands the text on as it was stored | +| N13 | the collection panel stops asking about the failure window | 2 checks | +| N14 | the collection panel stops hiding what the caller may not read | hides a document the caller may not read | +| N15 | a started run is no longer protected from a collection-wide cancel | 3 checks | +| N16 | `/run` stops refusing a run nobody is recorded as having started | refuses a job nobody is recorded as having started | + +**Two mutations did not bite, and both were real findings.** + +1. **N2** — the first attempt moved a `running` guard *below* the outcome checks, which changes + nothing for any row the system actually produces. Re-aimed above them (N2b), and the check + reddened. The check was sound; the mutation was not. +2. **N4** — removing the `if (byHandle.has(row.handle)) continue;` from `assignmentsFromRows` + reddened nothing, because the `Map` is what deduplicates and the `continue` only chose which of a + run's identical rows won. The guard was dead weight dressed as a rule, and its docblock asserted + that rule. Both removed; N4b then reddened the checks by replacing the `Map` with an array. + +### The review passes + +**Complexity.** Scenario taken from history: *the collection panel gains another narrowing*, which +happened in 0.17.0 when `excludeGivenUpBefore` was added. Before this step that cost five edits — +`TaskFilter`, `toTaskFilter`, both runners' `findByCollection`, and the handler. After it, three — +the store method, its `where`, and the handler. READ does not rise: the query is in one place +instead of two implementations. No separate finding was admitted; the step is itself the fix. + +**Abstraction.** One finding, applied: `RunRecordStore.find(key)` had no caller. Step 1 wrote it for +"the routes", and the routes turned out to need `findByHandles` and `findByDocument` instead. +Checked mechanically rather than by eye, and removed with its two checks; four lines to write again +if step 6 wants it. Everything else on the interface now has a named caller. + +**Comments.** Every block in the diff was judged; one was deleted — `assignmentsFromRows` claimed +"the first row met answers for the run", which mutation N4 proved to be describing nothing. The +behavioural claims were checked rather than taken on trust: both collection reads lead on +`collectionSlug`, which is the leading column of the compound index the collection declares, and +`handle` and `settledAt` each carry their own, so "index-backed" is true as written. + +### Deviations from the contract + +1. **The collection panel answers with rows, not three counts (D1, as designed).** Recorded under + Decisions before any code was written: a count has no ids left for `visibleIds` to filter. +2. **Three store reads, not two.** The collection read split in half, for the reason now written + into D4. +3. **Two integration checks are skipped, not deleted.** `status-reads-are-bounded.int.test.ts` kept + two cases about jobs written in the legacy relationship shape. The panels no longer read job + rows at all, so a job queued before the upgrade is invisible to them until step 6 adopts it — + which is exactly what the design's build sequence says (R8 is step 6's). They are `it.skip` with + a comment naming the step that must restore them, rather than removed. + +### Findings carried forward + +- **A locale between attempts now reads as `running` where it read as `pending`.** The record is + told a locale failed only when the run gives up (`owedIfGaveUp`), so a row whose handler was + called and has not come back is indistinguishable from one being translated right now. Two + consequences, both now pinned by checks: the panel shows such a locale as Translating rather than + Queued, and `DELETE /cancel/:collection_slug` leaves its run alone, because one started locale + protects the whole run. The per-handle `DELETE /cancel` still reaches it. Closing this needs a + fact the port does not carry — that an attempt ended — and that is new surface, not this step's. +- **`JobStatusOutput.cancelled` is now always `false`.** Neither panel shows a cancelled row (D2), + and no client has ever read the field. It is a candidate for step 5's deprecation sweep. +- **`assignmentsOf` and `findByCollection` now have no production caller.** They stay on the port + until step 5 removes them; `SyncTaskRunner` and `PayloadJobsTaskRunner` still implement both, and + their own specs still cover them. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-27-the-pointer-input.task.md b/packages/payload-plugin-translator/docs/plans/2026-10-27-the-pointer-input.task.md new file mode 100644 index 000000000..bf54c3dbf --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-27-the-pointer-input.task.md @@ -0,0 +1,246 @@ +Build step: ./2026-10-26-the-plugin-keeps-its-own-record.design.md #6 + +# Step 6 — the job carries a pointer + +## Requirements / Task restatement + +The Payload Jobs runner still stores the whole run in its job row: collection, document, source +locale, per-locale filters, publish flag and requester. Every one of those now also lives in +`translator-runs`, which is the source of truth — so the job row states them a second time, and the +readers that parse them are the 468 lines nobody can remove. + +This step empties the job row down to a pointer. The queue carries the run's handle and its locale +list; each per-locale task carries the handle and its locale. Everything else the **plugin's** +handler reads from its own record, so the runner never looks at the table and never parses a run. + +A job queued before the upgrade is **adopted** rather than read in parallel: on first encounter the +plugin writes its rows and the job is thereafter indistinguishable from a new one. That is one file +and two call sites, which is what makes dropping the compatibility a deletion rather than a project. + +Covers R8 of the design, and finishes D4. + +## What already exists — platform · installed · project · verdict + +- **Platform.** A job's input fields become real columns, so `input.handle` is queryable the way + `input.collection_slug` already is (`store/jobsWhere.ts`). No JSON traversal is needed. +- **Installed.** Nothing new is added. +- **Project.** `report` is already the one road from the runner to the plugin, and `recordQueued` is + already create-if-absent — which is exactly what adoption needs, replayed rather than invented. +- **Verdict.** Nothing new is designed. One thing is renamed rather than added: what a report names. + +## Decisions + +**D1 — the plugin mints the handle; the rows are written before the job is queued.** +Chosen: `enqueue` mints a handle, reports `queued` for each locale — which writes the rows — and only +then queues the workflow carrying that handle. Lost: keeping `handle = String(job.id)`. + +The reason is a race, not tidiness. If the job existed first, a cron could pick it up before the rows +were written, and a handler that reads the table would find nothing, translate nothing and complete — +leaving rows that are written afterwards and never settled, which under step 4's rule blocks the +document for good. Rows-first has a failure of its own — a `queueWorkflow` that throws leaves rows +with no job — but that failure is loud (the enqueue rejects), visible (the panel shows the locales as +pending) and already has an escape hatch (cancel). A race that silently does nothing has none of +those. + +It also closes a hazard the port documents today: SQLite reuses a deleted row's id, so a handle kept +across a cancellation could later name another run. A minted handle cannot. + +**D2 — a report names the run and the locale; only `queued` describes the work.** +`report(payload, ref: RunRef, event, scope?)` with `RunRef = { handle, targetLng }`, and +`TaskEvent`'s `queued` variant carrying the work. Lost: `EnqueueAssignment` as every report's first +argument. After this step the runner *cannot* describe the work at terminal time — it does not know +the document any more — so a signature demanding it would force the runner to read the table, which +is the one thing this design keeps it out of. `EnqueueAssignment` stays as the **answer** to +`enqueue`, where the runner does know everything. + +**D3 — `TaskHandlerInput` is `{ handle, targetLng, previousAttempts }`.** +The runner knows nothing else to give. The plugin's handler reads the row and builds what +`translateDocument` needs. `TaskRunnerContext.collections` goes with it: its only two uses built the +legacy `collection` field this step stops writing. + +**D4 — adoption lives with the runner whose rows they are.** +`payload-jobs-runner/legacy/adoptLegacyJob.ts`, not `modules/runs/legacy/` as the design sketched. A +run record is runner-agnostic; a `payload-jobs` row written before 1.0 is not. The file carries the +old readers with it, so deleting the file deletes the compatibility. + +Adoption writes nothing itself: it replays `report(queued)` for each locale the old job names, which +is the same road a new run takes, and `recordQueued` leaves a row that is already there alone — so +adopting twice is adopting once. Then it stamps `handle` and `target_lngs` onto the job's input, and +the job is new-shaped from that moment. + +**D5 — an adopted job's handle is its own id.** +The only name it has ever had: the enqueue answer gave it, a host may have stored it, and the cancel +route still takes it. Minting a fresh one would strand every handle a host is holding. + +**D6 — the legacy deprecations are isolated, not removed.** +`jobs-input-collection-field`, `jobs-per-locale-task-shape` and `the-strategy-leaves-the-stored-run` +stay `live`. Step 5 deferred the first of them here on the grounds that removing the read fallback +would strand a job queued before 0.3.0 — and that is still true, because R8 says such a job must keep +running. What changes is that all three now live in one file with two call sites. Their ledger entries +say so, and say what deleting that file costs. + +**New surface.** `RunRef`, published, because a runner author names it. The shape check fired on an +exported function changing a parameter's type: two shapes were weighed — `RunRef` as a new type, and +narrowing `EnqueueAssignment` itself to the two fields. The separate type wins because +`EnqueueAssignment` is still answered in full by `enqueue`, and one type cannot be two widths. + +**Written contract.** Adoption owes callers two things its signature cannot state: it is idempotent, +and a job it has adopted is never adopted again. AC5 and AC6 pin them. + +## Acceptance Criteria + +| AC | Covers | Criterion | How it is checked | Passes when | +|---|---|---|---|---| +| AC1 | R8 | A queued job's input carries the handle and the locale list, and nothing else | `bunx vitest run src/server/modules/task-runner/payload-jobs-runner/__tests__/jobInputFields.test.ts` | exit 0, the snapshot matches | +| AC2 | R8 | The rows exist before the job does | `bunx vitest run …/PayloadJobsTaskRunner.test.ts -t "before"` | exit 0, the case runs | +| AC3 | R8 | The plugin's handler is given the handle and the locale, and reads the rest from the record | `bunx vitest run src/server/features/translate-document/__tests__/readsTheRun.test.ts` | exit 0, the cases run | +| AC4 | R8 | A terminal report names the run and the locale, and the plugin finds the document itself | `bunx vitest run src/server/features/translate-document/__tests__/recordTheRun.test.ts` | exit 0, the cases run | +| AC5 | R8 | A job queued before the upgrade is adopted, and then runs | `apps/dev`: `bunx vitest run -c vitest.integration.config.ts src/integration/translator/legacy-job-adoption.int.test.ts` | exit 0, the cases run | +| AC6 | R8 | Adopting the same job twice leaves one row per locale | same file, `-t "twice"` | exit 0, the case runs | +| AC7 | R8 | A job queued before the upgrade is adopted at boot, and also when met without one | same file, `-t "independently"` | exit 0, the cases run | +| AC8 | R8 | Dropping the compatibility is one file and its call sites | `grep -rln "LegacyRunSchema\|LegacyCollectionSchema\|target_lngs" src` | only the legacy folder and the ledger | +| AC9 | R8 | `TaskRunnerContext.collections` is gone | `grep -rn "context.collections\|collections:" src/server/modules/task-runner` | no match on the context | +| AC10 | — | No new type errors | `bun run check-types` | 0 errors | +| AC11 | — | The package suite stays green | `bunx vitest run` | 0 failing | +| AC12 | — | No new lint findings | `bunx oxlint .` | ≤ 35 warnings, 0 errors | +| AC13 | — | The published package still builds under the real `tsc` | repo root: `bunx turbo run build --filter='./packages/payload-plugin-translator'` | exit 0 | +| AC14 | — | Integration suites stay green on all three adapters | `apps/dev`: `bun run test:integration` ×3 | 0 failing | +| AC15 | — | The two integration checks step 3 skipped are answered | `apps/dev`: `… status-reads-are-bounded.int.test.ts` and `… legacy-job-adoption.int.test.ts` | exit 0, nothing skipped in either | + +## Pre-flight + +Run against the tree as step 5 left it, on 2026-10-27. + +| AC | Command | Result | Kind | +|---|---|---|---| +| AC1 | `bunx vitest run …/jobInputFields.test.ts` | **PASS** today against a baseline of the *full* run shape — which is the before-state AC1 changes | change | +| AC2–AC7 | — | not run: the pointer and adoption do not exist | change | +| AC8 | `grep -rln "LegacyRunSchema\|LegacyCollectionSchema\|target_lngs" src` | **FAIL** — 9 files | change | +| AC9 | `grep -rn "collections" src/server/modules/task-runner` | **FAIL** — on `TaskRunnerContext` and both providers | change | +| AC10 | `bun run check-types` | **PASS** — 0 errors | invariant | +| AC11 | `bunx vitest run` | **PASS** — 2593 passing, 9 skipped | invariant | +| AC12 | `bunx oxlint .` | **PASS** — 35 warnings, 0 errors | invariant | +| AC13 | `bunx turbo run build --filter=…` | **PASS** — measured at the end of step 5 | invariant | +| AC14 | `bun run test:integration` ×3 | **PASS** — SQLite 240 / 10 skipped, PostgreSQL 246 / 4, MongoDB 238 / 12 | invariant | +| AC15 | `… status-reads-are-bounded.int.test.ts` | **PASS with 2 skipped** — the two cases step 3 parked for this step | change | + +## Risk notes + +- **This step can strand work that is already queued.** Everything about adoption is about not doing + that. The integration spec seeds a real pre-1.0 job row and runs it, on all three adapters, which + is the only honest way to check it. +- **Rows before the job is a deliberate inversion of the failure.** A failed queue now leaves + pending rows instead of a silent no-op. Recorded under D1; the escape hatch is the cancel button. +- **The per-terminal-event read is new cost.** The host's lifecycle callbacks name a document, and + after this step the runner cannot. One read per settlement, which the design's own cost model + budgeted. + +## Review log + +### Verification + +| AC | Command | Result | +|---|---|---| +| AC1 | `bunx vitest run …/jobInputFields.test.ts` | **PASS** — 5 cases; the baseline is now `handle` + `locales` on the workflow and `handle` + `target_lng` on the task, and one case lists twelve facts a job row may no longer state | +| AC2 | `bunx vitest run …/PayloadJobsTaskRunner.test.ts` | **PASS** — 30 cases, "writes the record before the job exists" among them | +| AC3 | `bunx vitest run …/readsTheRun.test.ts` | **PASS** — 11 cases | +| AC4 | `bunx vitest run …/recordTheRun.test.ts` | **PASS** — 14 cases | +| AC5–AC7 | `apps/dev`: `… legacy-job-adoption.int.test.ts` | **PASS** — 7 cases, on all three adapters | +| AC8 | `grep -rln "LegacyRunSchema\|target_lngs" src` | **PASS** — `legacy/legacyInput.ts` and its spec | +| AC9 | `grep -rn "collections" src/server/modules/task-runner` | **PASS** — two prose mentions of the plugin's own option, nothing on the context | +| AC10 | `bun run check-types` | **PASS** — 0 errors; `bunx turbo run check-types` 9/9 | +| AC11 | `bunx vitest run` | **PASS** — 2519 passing, 9 skipped, 0 failing (from 2593) | +| AC12 | `bunx oxlint .` | **PASS** — 35 warnings, 0 errors | +| AC13 | `bunx turbo run build --filter=…` | **PASS** | +| AC14 | `bun run test:integration` ×3 | **PASS** — SQLite 247 / 8 skipped, PostgreSQL 253 / 2, MongoDB 245 / 10 | +| AC15 | — | **answered, not restored** — see Deviations | + +### Mutations + +Twelve mutations, each applied to the implementation alone, the affected specs run, and the file +restored from a copy with the restore verified by comparison. + +| | What was broken | Went red | +|---|---|---| +| R1 | the record is written after the job is queued | writes the record before the job exists | +| R2 | the job row states a fact about the run again | 2 checks | +| R3 | the handler is handed a locale it was not asked for | hands on the handle and the locale the job row names | +| R4 | the plugin stops reading the run out of its own record | 2 checks | +| R5 | the requester is taken from the caller rather than from the run | writes under the rights of whoever asked | +| R6 | an unparseable stored filter is read as naming no filter | drops a filter entry the parser rejects | +| R7 | adoption invents a run for a row that says too little | 2 checks | +| R8 | the strategy a pre-1.0 run shared stops becoming the drift rule | turns the strategy into the drift rule | +| R9 | a reused handle silently keeps the older run's row | replaces a row whose key names a different document | +| R10 | the stamp and the translation stop naming the same locale | 2 checks | +| R11 | adoption never stamps the pointer onto the job | carries its pointer afterwards · is cancellable by its own id | +| R12 | the boot backfill is removed | 6 of the 7 adoption cases | + +R11 and R12 were run against the integration spec on a real database, rebuilding the package each +time, because adoption's two call sites only exist there. + +### The bug the integration suite found that no unit check could + +Adopting a pre-1.0 job names the run by the job's own id (D5), and **SQL hands a deleted row's id +back out**. The sequence that breaks: a new run is cancelled, its job row is deleted, a pre-upgrade +job is adopted and lands on the freed id — and `recordQueued`, whose rule since step 2 has been +"a key already recorded keeps the row it has", left the cancelled run's row in place and wrote +nothing. The adopted locale then had no row at all, and would have failed with `RunRecordMissing` +the moment it ran. + +It surfaced as one integration case failing only when the whole file ran, and passing alone — which +is exactly the shape of a test that has found something rather than one that is flaky. The fix is in +the rule, not in adoption: a row whose key names a **different document** is history of a run whose +job is gone, and is replaced. A unit check now pins it, and `isAbout` says why in one line. + +The port already documented this hazard for callers ("SQLite reuses a deleted row's id, so a handle +kept across a cancellation can later name another run"); what was new is that the plugin's own record +was exposed to it. + +### The review passes + +**Complexity.** Scenario taken from history: *a fact about a run changes shape*, which is what +`targets`, `strategy` and `collection` each did. Before: the fact lived in the job input, so it +needed a schema change, a reader, a fallback for rows already queued, and a migration note — and +those fallbacks accumulated across six files, which is why nobody could remove them. After: the job +input cannot carry a fact at all, so there is nothing to change shape; the record's column list and +its one migration are the whole of it. CHANGE for that scenario goes from 6 to 2, and the fallbacks +that existed are now in one folder, reached from two lines. + +**Abstraction.** Two findings, applied. `withLegacyCollection` had no caller once the task's input +stopped naming a collection — removed with its docblock. `store/` shrank from eleven files to six: +`readCollectionRef`, `readRequester`, `runTargets`, `assignmentOf` and `normalizeJob` either moved +into `legacy/` as one reader or went entirely, and `jobLog.ts` is what survived of `normalizeJob`. +`RunRef` was weighed against narrowing `EnqueueAssignment` itself: the separate type wins because +`enqueue` still answers in full, and one type cannot be two widths. + +**Comments.** Every block in the diff was judged. The legacy folder's two headers are the load- +bearing ones — they say what deleting the folder costs, which is the fact a future reader needs and +cannot derive. Three comments were repaired rather than written: the concurrency key's reason (it +keys a run now, not a document), the task fixture's docblock (it described `pickHost`, which is +gone), and the ledger's two `Code refs` lists. + +### Deviations from the contract + +1. **AC15 is answered, not satisfied as written.** The two cases step 3 parked asserted that the + *panels read legacy job rows*. They never will again — a pre-upgrade job answers the panels by + being adopted, which is what `legacy-job-adoption.int.test.ts` checks in seven cases, two of them + on the panel and the cancel route. The parked cases were removed rather than restored, with the + legacy seed helper they used. +2. **Adoption lives in the runner, not in `modules/runs/legacy/` (D4).** Decided before the code was + written: a run record is runner-agnostic, a `payload-jobs` row is not. +3. **The three legacy deprecations stay `live` (D6).** They are now isolated to one folder rather + than removed, because R8 requires a job queued before the upgrade to keep running. The ledger + entries say where the fallback lives and what deleting it costs. +4. **`RequestShape` is private to the runner again.** Step 4 moved it into `store/types.ts` because + it described the job row's header; it no longer does, so it went back beside the code that groups + requests by it. + +### Findings carried forward + +- **An adopted run keeps its job's id as its handle, and new runs do not.** Two naming schemes + coexist by design (D5), and the one that is a reused integer is the one exposed to id recycling. + The rule above handles it; it is worth knowing when reading a database by hand. +- **A failed `queueWorkflow` now leaves rows with no job.** The deliberate inversion recorded in D1: + loud, visible on the panel, and cancellable, against a silent race that was none of those. +- **One read per settlement is new cost**, because the host's callbacks name a document the runner + no longer knows. Budgeted by the design; worth watching on a many-locale document. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-27-the-port-narrows.task.md b/packages/payload-plugin-translator/docs/plans/2026-10-27-the-port-narrows.task.md new file mode 100644 index 000000000..c3ede4763 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-27-the-port-narrows.task.md @@ -0,0 +1,229 @@ +Build step: ./2026-10-26-the-plugin-keeps-its-own-record.design.md #5 + +# Step 5 — the port narrows + +# Requirements / Task restatement + +`TaskRunner` has five required members. Steps 2–4 left three of them with no caller: the plugin +answers "which runs exist for this document" and "which document does this handle name" from its own +record, and decides for itself whether a document may take new work. + +This step takes them off the port. What is left is `enqueue`, required, plus two capabilities — +`stop` and `run` — that a runner may or may not have. **A route whose capability the runner lacks is +not registered, and the control that calls it is not drawn.** `createSyncRunner` keeps nothing in +memory any more, which settles the third of the three host-facing warts the design opened with. + +Covers R5 of the design. + +## What already exists — platform · installed · project · verdict + +- **Platform.** Nothing new. Payload only ever sees the endpoints the plugin hands it, so "not + registered" needs no support from it. +- **Installed.** Nothing new is added. +- **Project.** `assertTaskRunnerContract` is already the published place a runner author checks + itself against, and `everyRegisteredRouteIsGuarded.test.ts` already boots the plugin and counts + the routes — which is exactly the shape the capability check needs. +- **Verdict.** Nothing new is designed. One concept is named: what a runner can do beyond accepting + work. + +## Decisions + +**D1 — capabilities are declared by the provider, not sniffed from the runner.** +Chosen: `TaskRunnerProvider.capabilities: RunnerCapabilities`, two required booleans. Lost: reading +`typeof runner.stop === "function"` off an instance. Routes are registered at config time and a +runner instance needs a `payload` that does not exist yet, so sniffing would mean building a runner +against a stand-in. Declaring is also checkable: the conformance suite asserts that what a provider +declares, its runner implements, so the two cannot drift silently. + +**D2 — both booleans are required, with no default.** +Chosen: a provider states both. Lost: `capabilities?` defaulting to "neither". A default would +quietly unregister `/cancel` for every runner written before this major, and the failure would look +like a missing feature rather than a missing line. Required means the upgrade fails to compile, +which is the loud version of the same news. + +**D3 — `cancel` becomes `stop`.** +Chosen: the rename travels with the narrowing. Lost: keeping the name. `cancel` is already the name +of the *route* and of the `cancelled` event, and on a runner that translates inline there is nothing +to cancel — only work to stop as far as it can. One word, one meaning. + +**D4 — the sync runner keeps nothing.** +`SyncTaskRunner` loses its in-memory map, and `createSyncRunner` loses the `maxSize`/`ttlMs` options +that bounded it. The map existed to answer status queries; the record answers them now. This is the +README `:398` wart — "last 100 tasks, one hour, forgotten on restart" — and it goes because nothing +reads the map, not because it was tuned better. + +**D5 — the two cancel routes are *not* unified, although the ledger plans it.** +`cancel-by-collection-route` stays. Its stated reason for going is that the two routes differ only +in a where-clause, but they also carry two different `TranslatorAction`s, and collapsing them would +take away a host's ability to allow one and refuse the other. That is the owner's call, not a +tidy-up, so it stays planned. Recorded as a deviation from the design's list. + +**D6 — `jobs-input-collection-field` is deferred to step 6.** +The design lists it here. It is the *stored job input's* shape, which step 6 rewrites into a +pointer and gives an adoption path for old rows; removing the read fallback now would strand a job +queued before 0.3.0 with no way to read it back. Recorded as a deviation. + +**New surface.** `RunnerCapabilities`, published, because a host writing a runner must name it. The +shape check fired on "an exported function gains a parameter" and on a field added to a published +type: two shapes were weighed — two booleans on the provider, and a `capabilities()` method on the +runner. The booleans win on the count that matters: the next capability costs one field and one +registration line, against a method whose absence is itself the answer and so cannot be asserted. + +**Written contract.** `RunnerCapabilities` owes something its type cannot state: a provider that +declares a capability must return a runner that implements it. The conformance suite asserts it; +AC4 pins that. + +## Acceptance Criteria + +| AC | Covers | Criterion | How it is checked | Passes when | +|---|---|---|---|---| +| AC1 | R5 | A runner that implements `enqueue` alone satisfies the port | `bun run check-types` plus `bunx vitest run src/server/modules/task-runner/sync-runner/` | 0 errors, the cases run | +| AC2 | R5 | With such a runner, the cancel and run routes are not registered | `bunx vitest run src/server/shared/access/__tests__/everyRegisteredRouteIsGuarded.test.ts` | exit 0, the cases run | +| AC3 | R5 | With a runner that has both capabilities, every route is registered | same file | exit 0, the case runs | +| AC4 | R5 | A provider that declares a capability it does not implement fails the published conformance suite | `bunx vitest run src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.contract.test.ts` | exit 0, the new case runs | +| AC5 | R5 | The collection panel draws no cancel control when the runner cannot stop | `bunx vitest run src/client/features/collection-translation-progress/` | exit 0, the cases run | +| AC6 | R5 | `assignmentsOf`, `findByCollection`, `TaskFilter` and `toTaskFilter` are gone from the package | `grep -rE "assignmentsOf\|findByCollection\|toTaskFilter" src` | no match outside the deprecations ledger | +| AC7 | R5 | `enqueue` answers with assignments, never with nothing | `grep -n "EnqueueAssignment\[\] \| void" src` | no match | +| AC8 | R5 | The sync runner keeps nothing in memory between requests | `grep -rn "LazyMap" src/server/modules/task-runner` | no match | +| AC9 | R5 | On a real database every route still answers with the Payload Jobs runner | `apps/dev`: `bun run test:integration` | 0 failing | +| AC10 | R5 | On a real database the sync runner still translates, and the routes it cannot serve are absent | `apps/dev`: `bunx vitest run -c vitest.integration.config.ts src/integration/translator/runner-capabilities.int.test.ts` | exit 0, the cases run | +| AC11 | — | No new type errors | `bun run check-types` | 0 errors | +| AC12 | — | The package suite stays green | `bunx vitest run` | 0 failing | +| AC13 | — | No new lint findings | `bunx oxlint .` | ≤ 37 warnings, 0 errors | +| AC14 | — | The published package still builds under the real `tsc` | repo root: `bunx turbo run build --filter='./packages/payload-plugin-translator'` | exit 0 | +| AC15 | — | Integration suites stay green on all three adapters | `apps/dev`: `bun run test:integration` ×3 | 0 failing | +| AC16 | — | Every removal is in the ledger, with what replaced it | read `docs/DEPRECATIONS.md` | each removed symbol has an entry marked `removed` with today's date | + +## Pre-flight + +Run against the tree as step 4 left it, on 2026-10-27. + +| AC | Command | Result | Kind | +|---|---|---|---| +| AC1–AC5, AC10 | — | not run: capabilities do not exist, and every route is registered unconditionally | change | +| AC6 | `grep -rE "assignmentsOf\|findByCollection\|toTaskFilter" src` | **FAIL** — 30 matches across the port, both runners and their specs | change | +| AC7 | `grep -n "EnqueueAssignment\[\] \| void"` | **FAIL** — one match, `TaskRunner.enqueue` | change | +| AC8 | `grep -rn "LazyMap" src/server/modules/task-runner` | **FAIL** — the sync runner's store | change | +| AC11 | `bun run check-types` | **PASS** — 0 errors | invariant | +| AC12 | `bunx vitest run` | **PASS** — 2645 passing | invariant | +| AC13 | `bunx oxlint .` | **PASS** — 37 warnings, 0 errors | invariant | +| AC14 | `bunx turbo run build --filter=…` | **PASS** — measured at the end of step 4 | invariant | +| AC15, AC9 | `bun run test:integration` ×3 | **PASS** — SQLite 242 / 10 skipped, PostgreSQL 248 / 4, MongoDB 240 / 12 | invariant | + +## Risk notes + +- **This is the step a host feels.** Every runner a host wrote stops compiling, which is the point of + a major — but the ledger entries are what turn a compile error into an upgrade instruction. AC16 + is there because an undocumented removal is indistinguishable from a bug. +- **A route that is not registered answers 404, not 501.** A client that calls `/cancel` against a + runner that cannot stop gets Payload's own not-found. The control is not drawn, so the plugin's + own admin never does; a host's own client might, and the README should say so. +- **The conformance suite's cancel checks become conditional.** A suite that skips most of itself is + worth less than one that fails, so the skipping has to be visible: the checks are registered only + when the capability is declared, and the `describe` says which. + +## Review log + +### Verification + +| AC | Command | Result | +|---|---|---| +| AC1 | `bun run check-types` + `bunx vitest run …/sync-runner/` | **PASS** — `SyncTaskRunner` implements `enqueue` and nothing else | +| AC2, AC3 | `bunx vitest run …/everyRegisteredRouteIsGuarded.test.ts` | **PASS** — 5 cases: nine routes with a capable runner, six with an enqueue-only one | +| AC4 | `bunx vitest run …/SyncTaskRunner.contract.test.ts` | **PASS** — the declaration check runs, the `stop` blocks register themselves away | +| AC5 | `bunx vitest run src/client/features/collection-translation-progress/` | **PASS** — 10 cases, 3 new | +| AC6 | `grep -rE "assignmentsOf\|findByCollection\|toTaskFilter" src` | **PASS** — the only matches are two prose references, both naming what went | +| AC7 | `grep -n "EnqueueAssignment[] \| void" src` | **PASS** — no match | +| AC8 | `grep -rn "LazyMap" src/server/modules/task-runner` | **PASS** — no match; `LazyMap` itself had no caller left and was deleted | +| AC9, AC15 | `bun run test:integration` ×3 | **PASS** — SQLite 240 / 10 skipped, PostgreSQL 246 / 4, MongoDB 238 / 12 | +| AC10 | `apps/dev`: `… runner-capabilities.int.test.ts` | **PASS** — 3 cases | +| AC11 | `bun run check-types` | **PASS** — 0 errors | +| AC12 | `bunx vitest run` | **PASS** — 2593 passing, 9 skipped, 0 failing (from 2645) | +| AC13 | `bunx oxlint .` | **PASS** — 35 warnings, 0 errors (from 37) | +| AC14 | `bunx turbo run build --filter=…` | **PASS**; `bunx turbo run check-types` 9/9 | +| AC16 | `docs/DEPRECATIONS.md` | **PASS** — one new entry, `the-runner-only-accepts-work`; `find-by-collection-document-ids-array` and `enqueue-void-return` moved to `removed` with today's date | + +**The nine skipped checks are the point.** They are the `stop` blocks of the conformance suite, +registered away by `describe.runIf` for a runner that declares `stop: false`. The suite says so in +its own names rather than passing quietly. + +### Mutations + +Thirteen mutations, each applied to the implementation alone, the affected specs run, and the file +restored from a copy with the restore verified by comparison. + +| | What was broken | Went red | +|---|---|---| +| Q1 | the cancel route is registered whatever the runner can do | leaves out the routes an enqueue-only runner cannot serve | +| Q2 | the run-now route is registered whatever the runner can do | the same check | +| Q3 | the status routes are gated on a capability they do not need | 2 checks | +| Q4 | the sync runner claims it can stop work | 10 checks, the declaration check among them | +| Q5 | the cancel control is drawn whatever the runner can do | is not drawn when the runner cannot | +| Q6b | the capability stops reaching the dashboard at composition | tells the bulk dashboard whether the runner can stop | +| Q6c | the export drops the capability from its server props | the same check | +| Q7b | the cancel handler calls a runner that cannot stop | is answered rather than called | +| Q7c | the run handler calls a runner that cannot run on demand | is answered rather than called | +| Q8 | the sync runner serves a locale twice when the request lists it twice | serves a locale once | + +**Two mutations did not bite, and the two outcomes are different.** + +1. **Q6 and Q7 were real gaps.** Nothing checked that the capability survives the trip from the + provider to the dashboard's server props, and nothing checked that a handler meets a runner + without the capability with an answer rather than a crash. Three checks added — two on the + handlers, one over the composition — and Q6b, Q6c, Q7b and Q7c then reddened them. +2. **Q9 was withdrawn as an invalid mutation.** It replaced the declaration check's left-hand side + with its right-hand side, making the assertion compare `capabilities` with itself. Nothing + reddened because a check cannot check itself; the mutation changed the test, not the + implementation. Q4 is the valid mutation for that check, and it reddened ten cases. + +**The one hand-off still unchecked** is the JSX line in `BulkTranslationDashboard.tsx` that passes +`canStop` to the panel. Both ends of the chain are pinned — composition to server props, and the +panel's own behaviour — but rendering the dashboard itself would need most of Payload's admin +context for one prop. Recorded rather than faked. + +### The review passes + +**Complexity.** Scenario: *a host writes a runner of their own*, which is the scenario this whole +step is for. Before: five required members, three of which needed the runner to keep a searchable +store of its own. After: one required member, two optional capabilities, and a declaration of which. +READ for that host falls from the whole `TaskFilter`/`Task` vocabulary to `EnqueueAssignment`. The +second scenario, *a new capability appears*, costs five edits — the type, the registration line, the +route, and the two built-in declarations — against a port change that would previously have broken +every runner in existence. + +**Abstraction.** One finding, applied: `LazyMap` had no caller once the sync runner stopped keeping +anything, so the class, its spec and its barrel line went. `RunnerCapabilities` itself was weighed +against a `capabilities()` method on the runner: the booleans win because an absent method is its +own answer and so cannot be asserted, while a declaration that disagrees with the implementation is +exactly what the conformance suite now catches. + +**Comments.** Every block in the diff was judged. Two were repaired rather than written: a comment +in the Payload Jobs runner's spec pointed at `findByCollection` for a drizzle quirk that is not +`findByCollection`'s, and the `TaskRunner` interface docblock gained a `@since` line naming what +left. The two `notImplemented` guards carry the only genuinely non-obvious why in the step — they +are unreachable through the plugin's own wiring, and say so. + +### Deviations from the contract + +1. **The two cancel routes are not unified (D5).** The design lists `cancel-by-collection-route` + among this step's removals. It stays `planned`: the two routes carry two different + `TranslatorAction`s, and collapsing them takes away a host's ability to allow one and refuse the + other. That is the owner's call. +2. **`jobs-input-collection-field` is deferred to step 6 (D6).** It is the stored job input's shape, + which step 6 rewrites into a pointer and gives an adoption path for; removing the read fallback + now would strand a job queued before 0.3.0. +3. **`SyncRunnerOptions` went with the map.** Not on the design's list, but `maxSize` and `ttlMs` + only ever bounded the in-memory store, and `createSyncRunner` now takes no options at all. It is + in the ledger entry. + +### Findings carried forward + +- **A route that is not registered answers 404, not 501.** The plugin's own admin draws no control + for it, but a host's own client gets Payload's not-found. The handlers answer 501 if mounted by + hand. The README should say which, and that is the owner's text to write. +- **Three README warts are now settled in code and unsettled in prose.** `:401` (status dies with + the job row) was closed in step 2, `:398` (the sync runner forgets on restart) closes here, and + `:403` (pruning) gained its new rule in step 2 — a row with no `settledAt` must never be pruned. + All three need the README changed, which is the owner's to cut. +- **`TranslatorAction` still lists `cancel-by-collection`.** It is correct while that route exists; + it goes with the unification whenever that is decided. diff --git a/packages/payload-plugin-translator/docs/plans/2026-10-27-writing-the-run-record.task.md b/packages/payload-plugin-translator/docs/plans/2026-10-27-writing-the-run-record.task.md new file mode 100644 index 000000000..5ddc11988 --- /dev/null +++ b/packages/payload-plugin-translator/docs/plans/2026-10-27-writing-the-run-record.task.md @@ -0,0 +1,263 @@ +Build step: ./2026-10-26-the-plugin-keeps-its-own-record.design.md #2 + +# Step 2 — writing the run record + +## Requirements / Task restatement + +Step 1 registered `translator-runs` and nothing touched it. This step makes the plugin write it: +every locale of every run gets a row when it is queued, a stamp when the plugin's own handler is +called for it, and a settlement when the plugin is told what became of it. The stored failure text is +sanitised before it reaches the database, not on the way out of it. + +Nothing *reads* the table yet — that is step 3 — so no route, panel or policy changes behaviour. What +changes for a host is that a translation now costs three small writes per locale, and that a failed +`queued` write fails the request that asked for the translation. + +Covers R3, R6 and R7 of the design. + +## What already exists — platform · installed · project · verdict + +- **Platform.** Payload's `update({ where })` answers `{ docs, errors }` and does not throw when the + filter matches nothing, so a patch against a missing row is a silent no-op. That decides AC12 + below rather than inviting a guard. +- **Installed.** Nothing new is added. +- **Project.** Four pieces already exist and are used rather than re-invented: + - `swallowOrThrow` (`server/shared/payload/swallowOrThrow.ts`) is exactly the write policy the + design asks for — propagate inside the caller's transaction, log outside it. + - `mustPropagate` already treats a foreign error as "assume it rolled the caller back", which is + the behaviour the design asked a new error class to supply (see D4). + - `toClientErrorMessage` (`server/shared/http/toClientErrorMessage.ts`) is the sanitiser R7 needs. + - `LifecycleNotifier` is the existing best-effort half of `report`; the row write is the + load-bearing half placed in front of it. +- **Verdict.** Nothing new is designed. One new concept is added — the ask carried by a `queued` + event — and it is argued below. + +## Decisions + +**D1 — the ask rides on the `queued` event, not on `EnqueueAssignment`.** +The row stores `filters` and `publishOnTranslation`; neither is on `EnqueueAssignment`, so `report` +cannot write a complete identity today. Two shapes were weighed. + +| | `EnqueueAssignment` carries the ask | the `queued` event carries it | +|---|---|---| +| next field of this kind costs | 4 edits — the type, both construction sites, and `assignmentOf` | 3 edits — the type and both construction sites | +| states admitted vs produced | `assignmentOf` builds assignments for *terminal* events, where no ask exists; it would have to invent one | every `queued` has an ask, every terminal event has none — gap 0 | +| second caller already present | the enqueue route returns assignments to the browser, which would ship filters it does not use | none, but `TaskEvent` is already a union whose variants differ by payload (`failed` carries `error`) | + +Chosen: the event. It loses nothing and it follows the shape the same type already has. + +**D2 — three narrow writes, replacing step 1's `upsert`.** +Chosen: `recordQueued(identity)` · `recordStarted(key, start)` · `recordSettled(key, settlement)`. +Lost: the single `upsert(record: RunRecord)` step 1 shipped. Two reasons, and the first is a defect +rather than a preference. `upsert` replaces the whole row, so the second `queued` report for a locale +an existing run already covers — which the runner contract explicitly promises to send — would wipe +that run's `startedAt` back to empty. Merging dies in step 4, but step 2 must be correct on its own. +Second, the started stamp does not know `queuedAt` and the settlement does not know the identity, so +one whole-row write forces a read before every write. Narrow methods demand only what their caller +has. Step 1's store tests are rewritten against the three. + +**D3 — `recordQueued` leaves an existing row alone.** +Chosen: find by key, and return if a row is there. Lost: last-write-wins. The key already exists only +when the run already covers that locale, and in that case the row on disk is further along than the +report that arrived. + +**D4 — no new error class; `mustPropagate`'s default already is the rule.** +This deviates from the design, which asks for "a `TranslatorError` with `mustPropagate = true`". A +failure out of `payload.create`/`payload.update` is a foreign error, and `mustPropagate` returns +`true` for every foreign error by design — "assume it rolled the caller's transaction back". A class +that only restates the default would be a concept with no decision in it. The context the class would +have carried (*which* write failed) is supplied by the log line instead. Recorded as a deviation; it +is one file to add if the owner wants the distinct message. + +**D5 — the queued write is fatal, the stamp and the settlement are not.** +`recordQueued` is called bare, so a failure leaves `report` and fails `enqueue` — R6, and the reason +is correctness: a missing row means the next request sees no live run and queues a second one over +the same document. `recordStarted` and `recordSettled` go through `swallowOrThrow`, so inside a +transaction they propagate (it is already dead) and outside one they are logged. The alternative — +making the settlement fatal too — turns one database hiccup into a retried translation the host pays +for twice, and the design already accepts an unsettled row as the failure mode ("the escape hatch is +the cancel button"). + +**D6 — `translateDocument` takes `Omit`.** +`TaskHandlerInput` gains `handle` because the stamp needs it. `translateDocument` never reads it, and +seven handler specs construct that input directly; `Omit` keeps them honest and states the fact — +everything the handler is given *except* the handle, which is the record's business. `openRun` in the +same module already narrows this type with `Pick`. + +**New surface.** Nothing is added to `src/index.ts`. Three published-port shapes change, all inside +the `1.0.0` major: `TaskEvent`'s `queued` variant gains `ask`, `TaskHandlerInput` gains `handle`, and +`TaskRunnerContext.report` gains an optional fourth argument. The shape check fired on the last of +those (an exported function gains a parameter, and it is optional): the parameter is optional because +a runner that enqueues outside any transaction has no scope to pass, which is a real state the system +produces, not a shrug. + +**Written contract.** `recordQueued` owes callers idempotency on `(handle, targetLng)` and the +promise that it does not disturb a row already there — neither is in its signature; AC2 pins both. +`report` owes the caller that the row is written before the host's callback fires, and that its own +failure is the enqueue's failure; AC9 pins the second. + +## Acceptance Criteria + +| AC | Covers | Criterion | How it is checked | Passes when | +|---|---|---|---|---| +| AC1 | R3 | Queuing a translation writes one row per locale carrying handle, collection, document, source, target, serialised filters, publish flag, requester and `queuedAt`, with every progress field empty | `bunx vitest run src/server/features/translate-document/__tests__/recordTheRun.test.ts -t "writes one row"` | exit 0, the case runs | +| AC2 | R3 | A second `queued` report for a key already recorded leaves the row as it stands | `bunx vitest run src/server/modules/runs/__tests__/store.test.ts -t "already recorded"` | exit 0, the case runs | +| AC3 | R3 | Calling the plugin's handler stamps `startedAt` and `attempts` before the translation runs | `… recordTheRun.test.ts -t "stamps"` | exit 0, the case runs | +| AC4 | R3 | A delivered locale is settled with `outcome: "delivered"`, a `settledAt`, and no error | `… recordTheRun.test.ts -t "delivered"` | exit 0, the case runs | +| AC5 | R3 | A failed locale is settled with `outcome: "failed"` and the failure text | `… recordTheRun.test.ts -t "failed"` | exit 0, the case runs | +| AC6 | R3 | A cancelled locale is settled with `outcome: "cancelled"` | `… recordTheRun.test.ts -t "cancelled"` | exit 0, the case runs | +| AC7 | R3 | A locale reported `cancelled` and then `delivered` ends as `delivered` | `… recordTheRun.test.ts -t "still settles"` | exit 0, the case runs | +| AC8 | R7 | A provider message carrying an API key is not what reaches the row | `… recordTheRun.test.ts -t "no provider detail"` | exit 0, the case runs | +| AC9 | R6 | A failing `queued` row write fails the report rather than leaving the run unrecorded | `… recordTheRun.test.ts -t "losing the run"` | exit 0, the case runs | +| AC10 | R6 | A failing settlement is logged outside a transaction and propagates inside one | `… recordTheRun.test.ts -t "transaction"` | exit 0, both cases run | +| AC11 | R6 | Every runner must let a throwing `queued` report out of `enqueue`; the published conformance suite asserts it | `bunx vitest run src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.contract.test.ts` | exit 0, the new case runs | +| AC12 | — | `swallowOrThrow` awaits an asynchronous `onFailure`, so the settlement written from one is not left in flight | `bunx vitest run src/server/shared/payload/__tests__/swallowOrThrow.test.ts -t "awaits"` | exit 0, the case runs | +| AC13 | R3 | On a real database, a translation through the enqueue route leaves one settled row per locale with `startedAt` set | `apps/dev`: `bunx vitest run -c vitest.integration.config.ts src/integration/translator/run-record-write.int.test.ts` | exit 0, the cases run | +| AC14 | R3 | With Payload's own default `deleteJobOnComplete: true`, the job row is gone and the run row still says `delivered` | same file, `-t "default"` | exit 0, the case runs | +| AC15 | — | No new type errors | `bun run check-types` | 0 errors | +| AC16 | — | The package suite stays green and grows | `bunx vitest run` | ≥ 2646 passing, 0 failing | +| AC17 | — | No new lint findings | `bunx oxlint .` | 43 warnings, 0 errors | +| AC18 | — | The published package still builds under the real `tsc` | repo root: `bunx turbo run build --filter='./packages/payload-plugin-translator'` | exit 0 | +| AC19 | — | Integration suites stay green on all three adapters | `apps/dev`: `bun run test:integration` with `DB_ADAPTER` unset, `postgres`, `mongo` | ≥ 235 / 241 / 233 passing, 0 failing | + +## Pre-flight + +Run against the tree as step 1 left it, on 2026-10-27. + +| AC | Command | Result | Kind | +|---|---|---|---| +| AC1–AC8, AC13, AC14 | — | not run: their subject does not exist. Nothing writes the table, so every row-content criterion fails by construction; AC16's count stands as the single before-number for the group | change | +| AC9, AC11 | — | not run: `report` has no row write to fail | change | +| AC10, AC12 | `bunx vitest run src/server/shared/payload/__tests__/swallowOrThrow.test.ts` | **PASS** today with 7 cases, none of which awaits `onFailure` — the gap AC12 names | change | +| AC15 | `bun run check-types` | **PASS** — 0 errors | invariant | +| AC16 | `bunx vitest run` | **PASS** — 212 files, 2646 passing | invariant | +| AC17 | `bunx oxlint .` | **PASS** — 43 warnings, 0 errors | invariant | +| AC18 | `bunx turbo run build --filter=…` | **PASS** — measured at the end of step 1 | invariant | +| AC19 | `bun run test:integration` ×3 | **PASS** — SQLite 235 / 8 skipped, PostgreSQL 241 / 2, MongoDB 233 / 10 (measured at the end of step 1) | invariant | + +## Risk notes + +- **The auto-translate path now writes inside the editor's save.** Four operations per locale, twenty + on a five-locale document, and a failure fails the save. The design decided this (D5) and the + owner confirmed it: "давай не будем тратить пользовательские деньги". What this step must not do + is make it worse than designed — the settlement is deliberately *not* fatal (D5 above). +- **`toClientErrorMessage` returns the raw message under `NODE_ENV=development|test`.** That is its + documented contract and it means AC8 is only true outside those environments. The spec sets the + environment it asserts under rather than pretending the function is unconditional. +- **Sanitising at the write collides with sanitising at the read, in step 3.** Applying + `toClientErrorMessage` twice collapses a reason's own copy to the generic text, because the + re-authored copy no longer carries the marker `readFailureReason` looks for. Step 3 must read the + column as already safe. Recorded here because this step is what makes it true. +- **A stamp or a settlement against a row that is not there does nothing, silently.** Payload's + filtered update reports no error for an empty match. It happens for a job queued before the + upgrade, which step 6 fixes by adopting it. Documented at both methods rather than guarded here. + +## Review log + +### Verification + +| AC | Command | Result | +|---|---|---| +| AC1–AC8, AC10 | `bunx vitest run src/server/features/translate-document/__tests__/recordTheRun.test.ts` | **PASS** — 15 cases | +| AC2, AC7 | `bunx vitest run src/server/modules/runs/__tests__/store.test.ts` | **PASS** — 14 cases | +| AC9 | `… recordTheRun.test.ts -t "losing the run"` | **PASS** | +| AC11 | `bunx vitest run …/sync-runner/__tests__/SyncTaskRunner.contract.test.ts` | **PASS** — 30 cases, the new invariant among them | +| AC12 | `bunx vitest run …/shared/payload/__tests__/swallowOrThrow.test.ts` | **PASS** — 8 cases | +| AC13 | `apps/dev`: `… run-record-write.int.test.ts` | **PASS** — 4 cases | +| AC14 | `apps/dev`: `… run-record-survives-the-job.int.test.ts` | **PASS** — 1 case | +| AC15 | `bun run check-types` | **PASS** — 0 errors | +| AC16 | `bunx vitest run` | **PASS** — 214 files, 2673 passing (from 2646) | +| AC17 | `bunx oxlint .` | **PASS** — 43 warnings, 0 errors | +| AC18 | `bunx turbo run build --filter=…` | **PASS**; `bunx turbo run check-types` 9/9 | +| AC19 | `bun run test:integration` ×3 | **PASS** — SQLite 241/8 skipped, PostgreSQL 247/2, MongoDB 239/10 (from 235 / 241 / 233) | + +### Mutations + +Twenty-five mutations, each applied to the implementation alone, the affected specs run, and the +file restored from a scratchpad copy with the restore verified by comparison. Every one reddened +exactly the check named beside it; `M15` and `M26` reddened two, both of which they genuinely break. + +| | What was broken | Went red | +|---|---|---| +| M1 | the ask stops reaching the row | writes one row carrying the ask | +| M2 | an absent requester stops reading as absent | records nobody when no requester | +| M3 | `queuedAt` stops being a moment | stamps the moment it was queued | +| M4 | the queued write becomes best-effort | fails the report rather than losing the run | +| M5 | the store stops being opened on the caller's transaction | opens the store on the caller's transaction | +| M6 | the start is stamped after the translation | stamps the start before the translation runs | +| M7 | the attempt is counted as the one already tried | counts the attempt as one past | +| M8 | a failed stamp stops the translation | translates anyway when the stamp cannot be written | +| M9 / M9b | a delivery is recorded as something else | records a delivery | +| M10 / M10b | a cancellation is recorded as something else | records a cancellation | +| M11 / M11b | the failure text reaches the row unsanitised | keeps provider detail out of the row | +| M12 / M12b | `settledAt` stops being a moment | stamps when the plugin was told | +| M13 / M13b | a failed settlement stops being survivable | logs and carries on outside a transaction | +| M14 | `onFailure` stops being awaited | awaits an `onFailure` that does work of its own | +| M15 | a patch names the run but not the locale | both key-scoped patch checks | +| M16 | a key already recorded is written over | leaves a key already recorded as it stands | +| M17 | an empty filter list stops meaning the whole document | stores nothing for an empty list | +| M18 | a job with no id is translated anyway | refuses a job the queue gave no id | +| M19 | the handle handed on is not the job's | names the run by the job's id | +| M20 | the runner swallows a failing queued report | lets a failing queued report out of enqueue | +| M21 | the write policy stops reading the caller's transaction | lets the failure out inside the transaction | +| M22 | a new row keeps progress it was not given | creates a row with no progress on it | +| M23 | a settled row refuses a later settlement | settles again over a row already settled | +| M25 | a lost race is treated as a failure | lets the winner's row stand | +| M26 / M27 | the stored filters are wrong or invented | the two remaining filter checks | + +**Two checks did not bite on the first attempt, and both were real weaknesses.** + +1. `swallowOrThrow` — "awaits an `onFailure`". The cleanup yielded on one resolved promise, which the + awaits unwinding out of `swallowOrThrow` outrun, so the ordering came out right with the `await` + removed. Rewritten against a timer, which a microtask cannot beat. M14 then reddened it. +2. The conformance invariant — "lets a failing queued report out of `enqueue`". The first version + refused *every* report, so a runner that swallowed the queued one still rejected over the + terminal one. Narrowed to refuse `queued` alone. M20 then reddened it. + +The integration criteria (AC13, AC14) carry no mutation: the guarantees they cover are the same ones +M1–M13 prove at the unit level, and a database round trip per mutation buys nothing the unit pass +does not already say. + +### The review passes + +**Complexity.** Scenario taken from the design's own sequence — "a fact is added to the run record", +which happens in steps 3, 4 and 6. CHANGE is 4 (the type, the collection's field list, the migration, +and whoever supplies the value) and cannot be fewer without generating one of the first two from the +other. One finding, applied: the three settling branches of `report` repeated the same five-line +write. Collapsed into one local `settle(outcome, error)`. CHANGE unchanged at 1 branch per outcome, +READ falls from 3 statements of how a settlement is written to 1. Re-proved by M9b–M13b. + +**Abstraction.** Four boundaries judged. One finding, applied: the runs barrel exported `RunIdentity` +and `RunRecordKey`, and no file outside the module names either — checked mechanically, by grep for +importers of the barrel. Removed. `RunRecordStore.find` and `findByDocument` stay unused for now; +step 3 is the documented next step that names them, which is the one case the package's barrel rule +allows. + +**Comments.** Two trimmed. `TaskHandlerInput.handle` said "the translation itself never reads it", +which `TranslateDocumentInput = Omit` now states structurally; the prose +went. The `noProgressYet` constant's comment restated its own name; replaced with the part that is +not obvious — the columns are written out as empty rather than left absent. Everything else kept: +each surviving comment says why, not what. + +### Deviations from the contract + +1. **No new error class (D4, as designed).** The design asked for a `TranslatorError` with + `mustPropagate = true`. `mustPropagate` already answers `true` for any foreign error, so the class + would have restated a default. Decided before implementation and recorded under Decisions. +2. **The integration spec is two files, not one.** `getPayload` caches one instance per process, so + the boot that needs Payload's own `deleteJobOnComplete: true` cannot share a file with the others. + AC14 points at `run-record-survives-the-job.int.test.ts`. +3. **The pre-flight said `swallowOrThrow` had 8 cases; it had 7.** The number was written without + running the command. Corrected above. + +### Findings carried forward + +- **A stamp or settlement against a missing row does nothing, silently.** Payload's filtered update + reports no error for an empty match. Reachable for a job queued before the upgrade; step 6 adopts + those. Documented at both store methods. +- **Step 3 must read `error` as already safe.** `toClientErrorMessage` applied twice collapses a + reason's own copy to the generic text, because the re-authored copy no longer carries the marker + `readFailureReason` looks for. +- **The auto-translate path now costs four operations per locale inside the editor's save**, against + zero before. Designed (D5) and confirmed by the owner, but it is the number to watch if a host + reports slow saves on a many-locale document. diff --git a/packages/payload-plugin-translator/src/__tests__/plugin.test.ts b/packages/payload-plugin-translator/src/__tests__/plugin.test.ts index 2e08e9b2a..3c9cf02f4 100644 --- a/packages/payload-plugin-translator/src/__tests__/plugin.test.ts +++ b/packages/payload-plugin-translator/src/__tests__/plugin.test.ts @@ -33,6 +33,7 @@ const makeCollection = () => ({ fields: [{ name: "title", type: "text", localized: true }], }); const makeRunner = () => ({ + capabilities: { stop: true, run: true }, create: vi.fn(), configure: vi.fn().mockReturnValue((c: Config) => c), }); @@ -161,6 +162,26 @@ describe("translatorPlugin — the listed surfaces", () => { }); describe("translatorPlugin — listing the surfaces one at a time", () => { + it.each([ + ["can stop work", { stop: true, run: true }, true], + ["cannot", { stop: false, run: false }, false], + ])( + "tells the bulk dashboard whether the runner %s, so it draws no control for a route that is not there", + async (_label, capabilities, expected) => { + const { posts } = await build({ + runner: { + capabilities, + create: vi.fn(), + configure: vi.fn().mockReturnValue((c: Config) => c), + } as unknown as TranslatorPluginConfig["runner"], + }); + + expect(posts.admin.components.beforeListTable[0].serverProps).toMatchObject({ + canStop: expected, + }); + } + ); + it("one control only → bundle present, doc popup yes, bulk dashboard no", async () => { const { result, posts } = await build({ modules: [documentTranslation()], @@ -188,6 +209,12 @@ describe("translatorPlugin — listing the surfaces one at a time", () => { }); }); +const markedProvenanceCleanup = (col: Record | undefined) => + (col?.hooks?.afterDelete ?? []).filter( + (h: { __translatorProvenanceCleanup?: string }) => + typeof h.__translatorProvenanceCleanup === "string" + ); + const provenanceOf = (result: Config, slug = "translator-provenance") => result.collections?.find((c) => c.slug === slug) as Record | undefined; @@ -246,7 +273,11 @@ describe("translatorPlugin — provenance (opt-in)", () => { translationProvider: { translate: vi.fn() }, // The plugin refuses to start without an access decision; these fixtures make the open one. access: new AnyAccessGuard(), - runner: { create: vi.fn(), configure: vi.fn().mockReturnValue((c: Config) => c) }, + runner: { + create: vi.fn(), + configure: vi.fn().mockReturnValue((c: Config) => c), + capabilities: { stop: true, run: true }, + }, modules: [documentTranslation()], components: [translateDocumentControl(), bulkTranslationDashboard()], provenance: {}, @@ -271,7 +302,11 @@ describe("translatorPlugin — provenance (opt-in)", () => { translationProvider: { translate: vi.fn() }, // The plugin refuses to start without an access decision; these fixtures make the open one. access: new AnyAccessGuard(), - runner: { create: vi.fn(), configure: vi.fn().mockReturnValue((c: Config) => c) }, + runner: { + create: vi.fn(), + configure: vi.fn().mockReturnValue((c: Config) => c), + capabilities: { stop: true, run: true }, + }, modules: [documentTranslation()], components: [translateDocumentControl(), bulkTranslationDashboard()], provenance: { slug }, @@ -288,12 +323,12 @@ describe("translatorPlugin — provenance (opt-in)", () => { it("attaches a delete-cleanup afterDelete hook to translatable collections when enabled", async () => { const { posts } = await build({ provenance: {} } as Partial); - expect(posts.hooks?.afterDelete).toHaveLength(1); + expect(markedProvenanceCleanup(posts)).toHaveLength(1); }); it("does not attach a cleanup hook when provenance is disabled", async () => { const { posts } = await build(); - expect(posts.hooks?.afterDelete ?? []).toHaveLength(0); + expect(markedProvenanceCleanup(posts)).toHaveLength(0); }); it("does not attach the cleanup hook to the sidecar collection itself", async () => { @@ -311,7 +346,11 @@ describe("translatorPlugin — provenance (opt-in)", () => { translationProvider: { translate: vi.fn() }, // The plugin refuses to start without an access decision; these fixtures make the open one. access: new AnyAccessGuard(), - runner: { create: vi.fn(), configure: vi.fn().mockReturnValue((c: Config) => c) }, + runner: { + create: vi.fn(), + configure: vi.fn().mockReturnValue((c: Config) => c), + capabilities: { stop: true, run: true }, + }, modules: [documentTranslation()], components: [translateDocumentControl(), bulkTranslationDashboard()], provenance: {}, @@ -323,7 +362,7 @@ describe("translatorPlugin — provenance (opt-in)", () => { const twice = await translatorPlugin(pluginConfig)(once); const posts = twice.collections?.find((c) => c.slug === "posts") as Record; - expect(posts.hooks?.afterDelete).toHaveLength(1); + expect(markedProvenanceCleanup(posts)).toHaveLength(1); }); }); @@ -398,17 +437,28 @@ describe("translatorPlugin — lifecycle callbacks", () => { ) => Promise; const handlerInput = (overrides: Record = {}) => ({ - collection: "posts", + handle: "run-1", + targetLng: "de", + previousAttempts: 0, + ...overrides, + }); + + const theRecordedRun = { + id: "row-1", + handle: "run-1", + collectionSlug: "posts", collectionId: "doc-1", sourceLng: "en", targetLng: "de", publishOnTranslation: false, - ...overrides, - }); + queuedAt: "2026-10-27T10:00:00.000Z", + }; - const mockPayload = () => + const mockPayload = (collectionSlug = "posts") => ({ findByID: vi.fn().mockResolvedValue({ id: "doc-1" }), + find: vi.fn().mockResolvedValue({ docs: [{ ...theRecordedRun, collectionSlug }] }), + update: vi.fn().mockResolvedValue({ docs: [] }), logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() }, collections: { posts: { config: { versions: undefined } } }, }) as unknown as Payload; @@ -429,7 +479,7 @@ describe("translatorPlugin — lifecycle callbacks", () => { const { runner } = await build({ lifecycle: {} } as Partial); await expect( - capturedHandler(runner)(mockPayload(), handlerInput({ collection: "unknown" })), + capturedHandler(runner)(mockPayload("unknown"), handlerInput()), "the runner decides what a throw means — it cannot if the handler swallows it" ).rejects.toBeDefined(); }); @@ -437,13 +487,13 @@ describe("translatorPlugin — lifecycle callbacks", () => { it.each([ ["onCompleted", "posts"], ["onFailed", "unknown"], - ])("does not call %s itself", async (callbackName, collection) => { + ])("does not call %s itself", async (callbackName, collectionSlug) => { const callback = vi.fn(); const { runner } = await build({ lifecycle: { [callbackName]: callback }, } as Partial); - await capturedHandler(runner)(mockPayload(), handlerInput({ collection })).catch( + await capturedHandler(runner)(mockPayload(collectionSlug), handlerInput()).catch( () => undefined ); @@ -466,11 +516,11 @@ describe("translatorPlugin — lifecycle callbacks", () => { const contextHandedToTheRunner = async (lifecycle: Record) => { let captured: TaskRunnerContext | undefined; const runner = { + capabilities: { stop: true, run: true }, create: vi.fn().mockReturnValue({ - enqueue: vi.fn().mockResolvedValue(undefined), - cancel: vi.fn(), + enqueue: vi.fn().mockResolvedValue([]), + stop: vi.fn(), run: vi.fn(), - findByCollection: vi.fn(), }), configure: vi.fn((context: TaskRunnerContext) => { captured = context; @@ -483,10 +533,38 @@ describe("translatorPlugin — lifecycle callbacks", () => { const payload = { logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() }, + find: vi.fn().mockResolvedValue({ + docs: [ + { + id: "row-1", + handle: "run-1", + collectionSlug: "posts", + collectionId: "doc-1", + sourceLng: "en", + targetLng: "de", + publishOnTranslation: false, + queuedAt: "2026-10-27T10:00:00.000Z", + }, + ], + }), + create: vi.fn().mockResolvedValue({ id: "row-1" }), + update: vi.fn().mockResolvedValue({ docs: [] }), } as unknown as Payload; const mappings: Array<[string, keyof TranslationLifecycleCallbacks, TaskEvent]> = [ - ["queued", "onQueued", { state: "queued" }], + [ + "queued", + "onQueued", + { + state: "queued", + work: { + collectionSlug: "posts" as CollectionSlug, + collectionId: "doc-1", + sourceLng: "en", + publishOnTranslation: false, + }, + }, + ], ["delivered", "onCompleted", { state: "delivered" }], ["failed", "onFailed", { state: "failed", error: new Error("provider down") }], ["cancelled", "onCancelled", { state: "cancelled" }], @@ -496,7 +574,7 @@ describe("translatorPlugin — lifecycle callbacks", () => { const callback = vi.fn(); const context = await contextHandedToTheRunner({ [callbackName]: callback }); - await context.report(payload, assignment, event); + await context.report(payload, { handle: "run-1", targetLng: "de" }, event); expect(callback, `${String(event)} must reach ${callbackName}`).toHaveBeenCalledWith( expect.objectContaining({ collection: "posts", id: "doc-1", targetLng: "de" }), diff --git a/packages/payload-plugin-translator/src/client/features/collection-translation-progress/ui/CollectionTranslationProgress.test.tsx b/packages/payload-plugin-translator/src/client/features/collection-translation-progress/ui/CollectionTranslationProgress.test.tsx index 90dfe82fa..9adc299a7 100644 --- a/packages/payload-plugin-translator/src/client/features/collection-translation-progress/ui/CollectionTranslationProgress.test.tsx +++ b/packages/payload-plugin-translator/src/client/features/collection-translation-progress/ui/CollectionTranslationProgress.test.tsx @@ -30,10 +30,15 @@ const panelWithWindow = (ms: number): CollectionTranslationPanel => ({ failedWindowMs: ms, }); -const show = (data: CollectionTranslationPanel) => +const show = (data: CollectionTranslationPanel, canStop = true) => render( - + ); @@ -73,4 +78,23 @@ describe("the collection progress panel", () => { expect(screen.queryByText(phrase), why).not.toBeNull(); }); + describe("the control for stopping queued work", () => { + it("is offered when the runner can stop work", () => { + show(panel({ [DocumentTranslationStatus.PENDING]: 3 })); + + expect(screen.getByLabelText("Cancel translations")).toBeDefined(); + }); + + it("is not drawn when the runner cannot, because no route is there to answer it", () => { + show(panel({ [DocumentTranslationStatus.PENDING]: 3 }), false); + + expect(screen.queryByLabelText("Cancel translations")).toBeNull(); + }); + + it("still shows how much is queued when the runner cannot stop it", () => { + show(panel({ [DocumentTranslationStatus.PENDING]: 3 }), false); + + expect(screen.getByText("3")).toBeDefined(); + }); + }); }); diff --git a/packages/payload-plugin-translator/src/client/features/collection-translation-progress/ui/CollectionTranslationProgress.tsx b/packages/payload-plugin-translator/src/client/features/collection-translation-progress/ui/CollectionTranslationProgress.tsx index a6c70e396..cabb60d43 100644 --- a/packages/payload-plugin-translator/src/client/features/collection-translation-progress/ui/CollectionTranslationProgress.tsx +++ b/packages/payload-plugin-translator/src/client/features/collection-translation-progress/ui/CollectionTranslationProgress.tsx @@ -20,6 +20,7 @@ type CollectionTranslationProgressProps = { collection: string; data: CollectionTranslationPanel | undefined; isPending: boolean; + canStop: boolean; }; const ROWS: Array<{ @@ -55,6 +56,7 @@ export function CollectionTranslationProgress({ collection, data, isPending, + canStop, }: CollectionTranslationProgressProps) { const cancelPendingTranslations = TranslationsApi.useCancelCollectionTranslations(); @@ -92,7 +94,7 @@ export function CollectionTranslationProgress({ {count} - {key === DocumentTranslationStatus.PENDING && ( + {canStop && key === DocumentTranslationStatus.PENDING && ( } title="Cancel translations" diff --git a/packages/payload-plugin-translator/src/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.export.tsx b/packages/payload-plugin-translator/src/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.export.tsx index 5cccd7526..929450e50 100644 --- a/packages/payload-plugin-translator/src/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.export.tsx +++ b/packages/payload-plugin-translator/src/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.export.tsx @@ -7,11 +7,19 @@ import { clientComponentPath } from "../../../shared/utils/componentPath.js"; export class BulkDocumentTranslationDashboard implements RawPayloadComponentExport { serverProps?: object | Record | undefined; + readonly access: AccessGuard; + readonly targetSelection: TargetSelectionMode; + readonly canStop: boolean; + constructor( - readonly access: AccessGuard = new AnyAccessGuard(), - readonly targetSelection: TargetSelectionMode = "single" + access: AccessGuard = new AnyAccessGuard(), + targetSelection: TargetSelectionMode = "single", + canStop = false ) { - this.serverProps = { access, targetSelection }; + this.access = access; + this.targetSelection = targetSelection; + this.canStop = canStop; + this.serverProps = { access, targetSelection, canStop }; } path = clientComponentPath( "widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.server" diff --git a/packages/payload-plugin-translator/src/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.server.tsx b/packages/payload-plugin-translator/src/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.server.tsx index ee36f331b..8bb64410b 100644 --- a/packages/payload-plugin-translator/src/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.server.tsx +++ b/packages/payload-plugin-translator/src/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.server.tsx @@ -12,6 +12,7 @@ import BulkTranslationDashboard from "./BulkTranslationDashboard.js"; type BulkTranslationDashboardServerProps = BeforeListTableServerProps & { access: AccessGuard; targetSelection: TargetSelectionMode; + canStop: boolean; }; const BulkTranslationDashboardServer = async (props: BulkTranslationDashboardServerProps) => { @@ -37,6 +38,7 @@ const BulkTranslationDashboardServer = async (props: BulkTranslationDashboardSer hasDrafts={hasDrafts} autoTranslate={autoTranslate} targetSelection={props.targetSelection} + canStop={props.canStop} /> ); }; diff --git a/packages/payload-plugin-translator/src/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.tsx b/packages/payload-plugin-translator/src/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.tsx index 9aa5a844b..9ca01b1c8 100644 --- a/packages/payload-plugin-translator/src/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.tsx +++ b/packages/payload-plugin-translator/src/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.tsx @@ -28,12 +28,14 @@ type BulkTranslationDashboardProps = { hasDrafts: boolean; autoTranslate: AutoTranslateSummary | null; targetSelection: TargetSelectionMode; + canStop: boolean; }; export default function BulkTranslationDashboard({ hasDrafts, autoTranslate, targetSelection, + canStop, }: BulkTranslationDashboardProps) { const locale = useLocale(); const { collection } = useCollectionDashboardUrlParams(); @@ -117,6 +119,7 @@ export default function BulkTranslationDashboard({ collection={collection} data={collectionTranslationApi.data} isPending={collectionTranslationApi.isPending} + canStop={canStop} /> )} diff --git a/packages/payload-plugin-translator/src/composition/components/bulkTranslationDashboard.ts b/packages/payload-plugin-translator/src/composition/components/bulkTranslationDashboard.ts index 17118c45e..46b9723d8 100644 --- a/packages/payload-plugin-translator/src/composition/components/bulkTranslationDashboard.ts +++ b/packages/payload-plugin-translator/src/composition/components/bulkTranslationDashboard.ts @@ -12,7 +12,12 @@ export function bulkTranslationDashboard(): TranslationComponent { attach(ctx) { ctx.addCollectionComponent( "beforeListTable", - () => new BulkDocumentTranslationDashboard(ctx.access, ctx.settings.targetSelection) + () => + new BulkDocumentTranslationDashboard( + ctx.access, + ctx.settings.targetSelection, + ctx.taskRunnerFactory.capabilities.stop + ) ); }, }; diff --git a/packages/payload-plugin-translator/src/composition/modules/documentTranslation.ts b/packages/payload-plugin-translator/src/composition/modules/documentTranslation.ts index 766133d49..865c61886 100644 --- a/packages/payload-plugin-translator/src/composition/modules/documentTranslation.ts +++ b/packages/payload-plugin-translator/src/composition/modules/documentTranslation.ts @@ -13,6 +13,7 @@ export function documentTranslation(): TranslationModule { ctx.addEndpoints( createTranslationRoutes({ taskRunnerFactory: ctx.taskRunnerFactory, + runRecordStoreFactory: ctx.runRecordStoreFactory, collectionConfig: { availableCollections: new Set(ctx.collections.map((collection) => collection.slug)), }, diff --git a/packages/payload-plugin-translator/src/core/domain/runs/__tests__/admitWork.test.ts b/packages/payload-plugin-translator/src/core/domain/runs/__tests__/admitWork.test.ts new file mode 100644 index 000000000..6ec1cd86d --- /dev/null +++ b/packages/payload-plugin-translator/src/core/domain/runs/__tests__/admitWork.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; + +import { admitWork } from "../admitWork.js"; + +const work = (collectionId: string, targetLng: string, collectionSlug = "posts") => ({ + collectionSlug, + collectionId, + targetLng, +}); + +const run = (collectionId: string, collectionSlug = "posts") => ({ + collectionSlug, + collectionId, +}); + +describe("one run per document at a time", () => { + it("admits work for a document nothing is translating", () => { + const { admitted, busyDocumentIds: busy } = admitWork({ + work: [work("d1", "de")], + inFlight: [], + }); + + expect(admitted.map((item) => item.collectionId)).toEqual(["d1"]); + expect(busy).toEqual([]); + }); + + it("turns away work for a document with a run still under way", () => { + const { admitted, busyDocumentIds: busy } = admitWork({ + work: [work("d1", "de")], + inFlight: [run("d1")], + }); + + expect(admitted).toEqual([]); + expect(busy).toEqual(["d1"]); + }); + + it("turns a document away for a locale the live run does not cover", () => { + const { admitted, busyDocumentIds: busy } = admitWork({ + work: [work("d1", "fr")], + inFlight: [run("d1")], + }); + + expect(admitted, "a second run would put the first run's locales back as they were").toEqual( + [] + ); + expect(busy).toEqual(["d1"]); + }); + + it("names a busy document once, however many locales were asked for it", () => { + const { busyDocumentIds: busy } = admitWork({ + work: [work("d1", "de"), work("d1", "fr"), work("d1", "es")], + inFlight: [run("d1")], + }); + + expect(busy).toEqual(["d1"]); + }); + + it("admits a free document alongside a busy one in the same request", () => { + const { admitted, busyDocumentIds: busy } = admitWork({ + work: [work("busy", "de"), work("free", "de")], + inFlight: [run("busy")], + }); + + expect(admitted.map((item) => item.collectionId)).toEqual(["free"]); + expect(busy).toEqual(["busy"]); + }); + + it("frees a document once its runs have settled, whatever they did", () => { + const { admitted, busyDocumentIds: busy } = admitWork({ + work: [work("d1", "de")], + inFlight: [], + }); + + expect(admitted, "the caller passes only unsettled runs; history is not liveness").toHaveLength( + 1 + ); + expect(busy).toEqual([]); + }); + + it("keeps two collections apart when they hold the same document id", () => { + const { admitted, busyDocumentIds: busy } = admitWork({ + work: [work("shared", "de", "posts"), work("shared", "de", "pages")], + inFlight: [run("shared", "pages")], + }); + + expect(admitted.map((item) => item.collectionSlug)).toEqual(["posts"]); + expect(busy).toEqual(["shared"]); + }); + + it("admits everything when nothing is under way", () => { + const { admitted } = admitWork({ + work: [work("d1", "de"), work("d2", "fr")], + inFlight: [], + }); + + expect(admitted).toHaveLength(2); + }); +}); diff --git a/packages/payload-plugin-translator/src/core/domain/runs/admitWork.ts b/packages/payload-plugin-translator/src/core/domain/runs/admitWork.ts new file mode 100644 index 000000000..c9a898e9f --- /dev/null +++ b/packages/payload-plugin-translator/src/core/domain/runs/admitWork.ts @@ -0,0 +1,37 @@ +export type OnADocument = { + collectionSlug: string; + collectionId: string; +}; + +export type Admission = { + admitted: Work[]; + busyDocumentIds: string[]; +}; + +/** Cannot occur in a slug or an id, so no two documents share an address. */ +const SEPARATOR_NO_IDENTIFIER_CARRIES = "\u0000"; + +const addressOf = (document: OnADocument): string => { + return `${document.collectionSlug}${SEPARATOR_NO_IDENTIFIER_CARRIES}${document.collectionId}`; +}; + +/** + * One run per document at a time: Payload rebuilds the whole document from the state a run read, so + * a later write puts the earlier run's locales back as they were. Any unsettled run blocks, however + * few locales it covers. + */ +export function admitWork(args: { + work: readonly Work[]; + inFlight: readonly OnADocument[]; +}): Admission { + const busyAddresses = new Set(args.inFlight.map(addressOf)); + const admitted: Work[] = []; + const busy = new Set(); + + for (const item of args.work) { + if (busyAddresses.has(addressOf(item))) busy.add(item.collectionId); + else admitted.push(item); + } + + return { admitted, busyDocumentIds: [...busy] }; +} diff --git a/packages/payload-plugin-translator/src/core/domain/runs/index.ts b/packages/payload-plugin-translator/src/core/domain/runs/index.ts new file mode 100644 index 000000000..2b4676f35 --- /dev/null +++ b/packages/payload-plugin-translator/src/core/domain/runs/index.ts @@ -0,0 +1 @@ +export { admitWork } from "./admitWork.js"; diff --git a/packages/payload-plugin-translator/src/index.ts b/packages/payload-plugin-translator/src/index.ts index 3e07d8421..6b73c3296 100644 --- a/packages/payload-plugin-translator/src/index.ts +++ b/packages/payload-plugin-translator/src/index.ts @@ -48,15 +48,11 @@ export { export type { TranslationFailureCode } from "./translation-providers/index.js"; // Task runners -export { - createPayloadJobsRunner, - createSyncRunner, - toTaskFilter, -} from "./server/modules/task-runner/index.js"; +export { createPayloadJobsRunner, createSyncRunner } from "./server/modules/task-runner/index.js"; export type { TaskRunnerProvider, PayloadJobsRunnerOptions, - TaskFilter, + RunnerCapabilities, } from "./server/modules/task-runner/index.js"; /** * A third-party `TaskRunnerProvider` has always had to accept a `RequestScope` — it is the second diff --git a/packages/payload-plugin-translator/src/plugin.ts b/packages/payload-plugin-translator/src/plugin.ts index 2d9f64498..8cb1e24a6 100644 --- a/packages/payload-plugin-translator/src/plugin.ts +++ b/packages/payload-plugin-translator/src/plugin.ts @@ -3,6 +3,7 @@ import type { CollectionConfig, Config } from "payload"; import { CacheProviderExport } from "./client/app/cache/CacheProvider.export.js"; import { configureAutoTranslate } from "./server/modules/auto-translate/index.js"; import { configureProvenance } from "./server/modules/provenance/index.js"; +import { configureRunRecord } from "./server/modules/runs/index.js"; import type { AccessGuard } from "./types/AccessGuard.js"; import type { CollectionSchemaMap } from "./types/CollectionSchemaMap.js"; import { projectFieldsToFieldLike } from "./core/kernel/field-traversal/index.js"; @@ -81,6 +82,20 @@ export type TranslatorPluginConfig = TranslatorSettingsOptions & { */ slug?: string; }; + /** + * Names the plugin's own record of translation runs. The collection is always added — the status, + * cancel and run-now routes read it — so `{ slug }` only resolves a collision with a collection you + * already own. **On SQL this adds a table**: `payload migrate:create` + `payload migrate` (dev push + * in development; MongoDB needs no migration). + * @since 1.0.0 + */ + runs?: { + /** + * Slug for the run record collection. + * @default 'translator-runs' + */ + slug?: string; + }; /** * Optional server-side lifecycle callbacks fired around each translation task * (`onQueued` / `onCompleted` / `onFailed`). Always available — no schema, no migration, not gated @@ -90,6 +105,27 @@ export type TranslatorPluginConfig = TranslatorSettingsOptions & { lifecycle?: TranslationLifecycleCallbacks; }; +/** Each field is required by the type, so only a plain-JavaScript caller reaches these. */ +function refuseAnUnusableConfig(pluginConfig: TranslatorPluginConfig): void { + if (!pluginConfig.access) { + throw new Error( + "payload-plugin-translator: `access` is required. Pass an access guard, or " + + "`new AnyAccessGuard()` to leave the translation endpoints open on purpose." + ); + } + + if (!pluginConfig.modules || !pluginConfig.components) { + throw new Error( + "payload-plugin-translator: `modules` and `components` are both required, and `[]` is a " + + "valid answer for either. `levels` is gone: `documentLevel()` becomes " + + "`modules: [documentTranslation()]` plus `components: [translateDocumentControl()]`, " + + "`collectionLevel()` becomes the same module plus " + + "`components: [bulkTranslationDashboard()]`, and `fieldLevel()` becomes " + + "`modules: [fieldTranslation()]`." + ); + } +} + /** * A module's name keys its slot in the run's `moduleState`, so two modules sharing one would share * the slot: the second overwrites, and the first reads back a value that is not its own. @@ -133,29 +169,12 @@ export function translatorPlugin( pluginConfig: TranslatorPluginConfig ): (config: Config) => Promise { return async (payloadConfig) => { - const { access, translationProvider, runner, collections, provenance, lifecycle } = + const { access, translationProvider, runner, collections, provenance, lifecycle, runs } = pluginConfig; const { modules, components } = pluginConfig; const settings = resolveSettings(pluginConfig); - // Required by the type, so only a plain-JavaScript caller reaches this. - if (!access) { - throw new Error( - "payload-plugin-translator: `access` is required. Pass an access guard, or " + - "`new AnyAccessGuard()` to leave the translation endpoints open on purpose." - ); - } - - if (!modules || !components) { - throw new Error( - "payload-plugin-translator: `modules` and `components` are both required, and `[]` is a " + - "valid answer for either. `levels` is gone: `documentLevel()` becomes " + - "`modules: [documentTranslation()]` plus `components: [translateDocumentControl()]`, " + - "`collectionLevel()` becomes the same module plus " + - "`components: [bulkTranslationDashboard()]`, and `fieldLevel()` becomes " + - "`modules: [fieldTranslation()]`." - ); - } + refuseAnUnusableConfig(pluginConfig); const schemaMap: CollectionSchemaMap = new Map( collections.map((col) => [col.slug, projectFieldsToFieldLike(col.fields)]) @@ -163,6 +182,7 @@ export function translatorPlugin( const collectionSlugs = new Set(schemaMap.keys()); const provenanceParts = configureProvenance(provenance, schemaMap); + const runRecord = configureRunRecord(runs); const chain: TranslationModule[] = provenanceParts ? [provenanceParts.module, ...modules] @@ -170,21 +190,29 @@ export function translatorPlugin( refuseDuplicateNames(chain); const { taskRunnerFactory, configModifier: runnerConfigModifier } = wireTranslateRunner({ - translationProvider, - schemaMap, - modules: chain, - criteria: provenanceParts?.criteria, + translation: { + translationProvider, + schemaMap, + modules: chain, + criteria: provenanceParts?.criteria, + }, runner, lifecycle: lifecycle ?? {}, - collections: Array.from(collectionSlugs), + storeFactory: runRecord.storeFactory, }); - const autoTranslateModule = configureAutoTranslate(collections, schemaMap, taskRunnerFactory); + const autoTranslateModule = configureAutoTranslate( + collections, + schemaMap, + taskRunnerFactory, + runRecord.storeFactory + ); const builder = new PluginConfigBuilder({ collections, settings, access, taskRunnerFactory, + runRecordStoreFactory: runRecord.storeFactory, schemaMap, translationProvider, }); @@ -193,6 +221,9 @@ export function translatorPlugin( builder.addConfigModifier(runnerConfigModifier); builder.addConfigModifier(autoTranslateModule.configure(collectionSlugs)); + builder.addConfigModifier(runRecord.refuseACollidingSlug); + builder.addConfigModifier(runRecord.addTheCollection); + builder.addConfigModifier(runRecord.cleanUpOnDelete(collectionSlugs)); builder.addAdminProvider(new CacheProviderExport(settings.basePath)); return builder.applyTo(payloadConfig); diff --git a/packages/payload-plugin-translator/src/server/features/cancel-by-collection/__tests__/handler.access.test.ts b/packages/payload-plugin-translator/src/server/features/cancel-by-collection/__tests__/handler.access.test.ts index b2b8abeff..6fd9c6316 100644 --- a/packages/payload-plugin-translator/src/server/features/cancel-by-collection/__tests__/handler.access.test.ts +++ b/packages/payload-plugin-translator/src/server/features/cancel-by-collection/__tests__/handler.access.test.ts @@ -1,7 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { CollectionSlug, Payload, PayloadRequest } from "payload"; -import type { Task, TaskRunner } from "../../../modules/task-runner/index.js"; +import type { TaskRunner } from "../../../modules/task-runner/index.js"; +import type { RunRecord } from "../../../modules/runs/index.js"; +import { runRow, storeDouble } from "../../../modules/runs/__tests__/fixtures/runRecordStore.js"; +import type { RunRecordStoreDouble } from "../../../modules/runs/__tests__/fixtures/runRecordStore.js"; vi.mock("../../_lib/collection-utils.js", () => ({ isCollectionAvailable: vi.fn((slug: string, available: Set) => @@ -18,18 +21,12 @@ const { refusedTargets } = await import("../../../shared/access/refusedTargets.j const ANNA = { id: "anna", collection: "users" }; -const queued = (handle: string, collectionId: string): Task => - ({ - id: handle, - status: "pending", - input: { collectionSlug: "posts", collectionId }, - createdAt: "", - updatedAt: "", - cancelled: false, - }) as unknown as Task; +const queued = (handle: string, collectionId: string): RunRecord => + runRow({ handle, collectionSlug: "posts" as CollectionSlug, collectionId }); describe("DELETE /cancel-by-collection asks about each document it would cancel", () => { let runner: TaskRunner; + let runs: RunRecordStoreDouble; let handler: InstanceType; const request = (): PayloadRequest => @@ -44,13 +41,18 @@ describe("DELETE /cancel-by-collection asks about each document it would cancel" (refusedTargets as unknown as ReturnType).mockResolvedValue(new Set()); runner = { enqueue: vi.fn(), - cancel: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), run: vi.fn(), - findByCollection: vi.fn().mockResolvedValue([queued("mine", "d1"), queued("theirs", "d2")]), } as unknown as TaskRunner; + runs = storeDouble({ + findLiveInCollection: vi + .fn() + .mockResolvedValue([queued("mine", "d1"), queued("theirs", "d2")]), + }); handler = new CancelByCollectionHandler( { availableCollections: new Set(["posts"] as CollectionSlug[]) }, - { create: vi.fn().mockReturnValue(runner) } + { create: vi.fn().mockReturnValue(runner), capabilities: { stop: true, run: true } }, + () => runs ); }); @@ -59,7 +61,7 @@ describe("DELETE /cancel-by-collection asks about each document it would cancel" await handler.handle(request()); - expect(runner.cancel).toHaveBeenCalledWith(["mine"]); + expect(runner.stop).toHaveBeenCalledWith(["mine"]); }); it("asks about the documents the rows name, for this caller", async () => { @@ -77,13 +79,13 @@ describe("DELETE /cancel-by-collection asks about each document it would cancel" const response = await handler.handle(request()); - expect(runner.cancel).not.toHaveBeenCalled(); + expect(runner.stop).not.toHaveBeenCalled(); expect(response.status).toBe(204); }); it("still cancels everything for a caller the rule allows", async () => { await handler.handle(request()); - expect(runner.cancel).toHaveBeenCalledWith(["mine", "theirs"]); + expect(runner.stop).toHaveBeenCalledWith(["mine", "theirs"]); }); }); diff --git a/packages/payload-plugin-translator/src/server/features/cancel-by-collection/handler.test.ts b/packages/payload-plugin-translator/src/server/features/cancel-by-collection/handler.test.ts index 7fa46129d..1b68bf5f0 100644 --- a/packages/payload-plugin-translator/src/server/features/cancel-by-collection/handler.test.ts +++ b/packages/payload-plugin-translator/src/server/features/cancel-by-collection/handler.test.ts @@ -1,32 +1,22 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Payload, PayloadRequest, CollectionSlug } from "payload"; + import { CancelByCollectionHandler } from "./handler.js"; import type { CancelConfig } from "./model.js"; -import type { TaskRunnerFactory, TaskRunner, Task } from "../../modules/task-runner/index.js"; +import type { TaskRunnerFactory, TaskRunner } from "../../modules/task-runner/index.js"; +import { runRow, storeDouble } from "../../modules/runs/__tests__/fixtures/runRecordStore.js"; +import type { RunRecordStoreDouble } from "../../modules/runs/__tests__/fixtures/runRecordStore.js"; + +const STARTED = "2026-10-27T10:01:00.000Z"; describe("CancelByCollectionHandler", () => { let handler: CancelByCollectionHandler; let mockTaskRunner: TaskRunner; let mockTaskRunnerFactory: TaskRunnerFactory; + let runs: RunRecordStoreDouble; let config: CancelConfig; - const createMockTask = (overrides: Partial = {}): Task => ({ - id: "task-123", - status: "pending", - input: { - collectionSlug: "posts" as CollectionSlug, - collectionId: "doc-123", - sourceLng: "en", - targetLng: "de", - publishOnTranslation: false, - }, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T01:00:00Z", - cancelled: false, - ...overrides, - }); - - const createMockRequest = (params: Record = {}): PayloadRequest => + const request = (params: Record = {}): PayloadRequest => ({ payload: {} as Payload, routeParams: params, @@ -35,140 +25,115 @@ describe("CancelByCollectionHandler", () => { beforeEach(() => { mockTaskRunner = { enqueue: vi.fn(), - cancel: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), run: vi.fn(), - assignmentsOf: vi.fn().mockResolvedValue([]), - findByCollection: vi.fn().mockResolvedValue([]), }; mockTaskRunnerFactory = { create: vi.fn().mockReturnValue(mockTaskRunner), + capabilities: { stop: true, run: true }, }; + runs = storeDouble(); + config = { availableCollections: new Set(["posts", "pages"] as CollectionSlug[]) }; - config = { - availableCollections: new Set(["posts", "pages"] as CollectionSlug[]), - }; - - handler = new CancelByCollectionHandler(config, mockTaskRunnerFactory); + handler = new CancelByCollectionHandler(config, mockTaskRunnerFactory, () => runs); }); describe("validation", () => { - it("returns validation error for missing collection_slug", async () => { - const req = createMockRequest({}); - - const response = await handler.handle(req); - - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.message).toBe("Validation error"); - }); - - it("returns validation error for empty collection_slug", async () => { - const req = createMockRequest({ collection_slug: "" }); - - const response = await handler.handle(req); + it("refuses a request naming no collection", async () => { + const response = await handler.handle(request({})); expect(response.status).toBe(400); + expect((await response.json()).message).toBe("Validation error"); }); - }); - - describe("collection availability", () => { - it("returns bad request for unavailable collection", async () => { - const req = createMockRequest({ collection_slug: "users" }); - const response = await handler.handle(req); + it("refuses a collection the plugin does not translate", async () => { + const response = await handler.handle(request({ collection_slug: "users" })); expect(response.status).toBe(400); - const body = await response.json(); - expect(body.message).toBe("Collection not available for translation"); + expect((await response.json()).message).toBe("Collection not available for translation"); }); }); - describe("no tasks to cancel", () => { - it("returns 204 when no tasks exist for collection", async () => { - (mockTaskRunner.findByCollection as ReturnType).mockResolvedValue([]); - - const req = createMockRequest({ collection_slug: "posts" }); - const response = await handler.handle(req); + describe("nothing to stop", () => { + it("answers 204 when the collection has nothing in flight", async () => { + const response = await handler.handle(request({ collection_slug: "posts" })); expect(response.status).toBe(204); expect(response.body).toBeNull(); + expect(mockTaskRunner.stop).not.toHaveBeenCalled(); }); - it("returns 204 when all tasks are running (not pending)", async () => { - const tasks = [ - createMockTask({ id: "task-1", status: "running" }), - createMockTask({ id: "task-2", status: "running" }), - ]; - (mockTaskRunner.findByCollection as ReturnType).mockResolvedValue(tasks); + it("answers 204 when every run has already started", async () => { + runs.findLiveInCollection.mockResolvedValue([ + runRow({ handle: "run-1", startedAt: STARTED }), + runRow({ handle: "run-2", startedAt: STARTED }), + ]); - const req = createMockRequest({ collection_slug: "posts" }); - const response = await handler.handle(req); + const response = await handler.handle(request({ collection_slug: "posts" })); expect(response.status).toBe(204); - expect(mockTaskRunner.cancel).not.toHaveBeenCalled(); + expect(mockTaskRunner.stop).not.toHaveBeenCalled(); }); }); - describe("cancelling queued jobs", () => { - it("cancels every queued job and leaves the one in flight alone", async () => { - const tasks = [ - createMockTask({ id: "task-1", status: "pending" }), - createMockTask({ id: "task-2", status: "running" }), - createMockTask({ id: "task-3", status: "pending" }), - ]; - (mockTaskRunner.findByCollection as ReturnType).mockResolvedValue(tasks); + describe("stopping what has not started", () => { + it("stops every queued run and leaves the one in flight alone", async () => { + runs.findLiveInCollection.mockResolvedValue([ + runRow({ handle: "run-1" }), + runRow({ handle: "run-2", startedAt: STARTED }), + runRow({ handle: "run-3" }), + ]); - const req = createMockRequest({ collection_slug: "posts" }); - const response = await handler.handle(req); + const response = await handler.handle(request({ collection_slug: "posts" })); expect(response.status).toBe(204); - expect(mockTaskRunner.cancel).toHaveBeenCalledWith(["task-1", "task-3"]); + expect(mockTaskRunner.stop).toHaveBeenCalledWith(["run-1", "run-3"]); }); - it("cancels a job waiting to retry after a failure", async () => { - const tasks = [ - createMockTask({ id: "task-1", status: "completed" }), - createMockTask({ id: "task-1", status: "failed" }), - ]; - (mockTaskRunner.findByCollection as ReturnType).mockResolvedValue(tasks); + it("protects a whole run when one of its locales has started", async () => { + runs.findLiveInCollection.mockResolvedValue([ + runRow({ handle: "run-1", targetLng: "de", startedAt: STARTED }), + runRow({ handle: "run-1", targetLng: "fr" }), + ]); - const req = createMockRequest({ collection_slug: "posts" }); - await handler.handle(req); + await handler.handle(request({ collection_slug: "posts" })); - expect(mockTaskRunner.cancel).toHaveBeenCalledWith(["task-1"]); + expect( + mockTaskRunner.stop, + "stopping the run would abandon the locale already being written" + ).not.toHaveBeenCalled(); }); - it("names each job once, however many locale rows it has", async () => { - const tasks = [ - createMockTask({ id: "task-1", status: "pending" }), - createMockTask({ id: "task-1", status: "pending" }), - ]; - (mockTaskRunner.findByCollection as ReturnType).mockResolvedValue(tasks); + it("names each run once, however many locale rows it has", async () => { + runs.findLiveInCollection.mockResolvedValue([ + runRow({ handle: "run-1", targetLng: "de" }), + runRow({ handle: "run-1", targetLng: "fr" }), + ]); - await handler.handle(createMockRequest({ collection_slug: "posts" })); + await handler.handle(request({ collection_slug: "posts" })); - expect(mockTaskRunner.cancel).toHaveBeenCalledWith(["task-1"]); + expect(mockTaskRunner.stop).toHaveBeenCalledWith(["run-1"]); }); - it("calls findByCollection with correct collection slug", async () => { - const req = createMockRequest({ collection_slug: "pages" }); - - await handler.handle(req); + it("asks the record what the collection has in flight", async () => { + await handler.handle(request({ collection_slug: "pages" })); - expect(mockTaskRunner.findByCollection).toHaveBeenCalledWith("pages", { - excludeCompleted: true, - }); + expect( + runs.findLiveInCollection, + "what the collection has in flight is the record's to answer, not the runner's" + ).toHaveBeenCalledWith("pages"); }); - it("creates task runner with request payload", async () => { - const mockPayload = { collections: {} } as Payload; - const req = createMockRequest({ collection_slug: "posts" }); - (req as any).payload = mockPayload; + it("creates the runner with the request's own payload", async () => { + const payload = { collections: {} } as Payload; + const req = request({ collection_slug: "posts" }); + (req as unknown as { payload: Payload }).payload = payload; + runs.findLiveInCollection.mockResolvedValue([runRow({ handle: "run-1" })]); await handler.handle(req); - expect(mockTaskRunnerFactory.create).toHaveBeenCalledWith(mockPayload); + expect(mockTaskRunnerFactory.create).toHaveBeenCalledWith(payload); }); }); }); diff --git a/packages/payload-plugin-translator/src/server/features/cancel-by-collection/handler.ts b/packages/payload-plugin-translator/src/server/features/cancel-by-collection/handler.ts index 6504e706b..c8ed4a4be 100644 --- a/packages/payload-plugin-translator/src/server/features/cancel-by-collection/handler.ts +++ b/packages/payload-plugin-translator/src/server/features/cancel-by-collection/handler.ts @@ -2,22 +2,39 @@ import type { PayloadRequest } from "payload"; import { ServerResponse } from "../../shared/index.js"; import type { TaskRunnerFactory } from "../../modules/task-runner/index.js"; +import { assignmentsFromRows } from "../../modules/runs/index.js"; +import type { RunRecord, RunRecordStoreFactory } from "../../modules/runs/index.js"; import { isCollectionAvailable } from "../_lib/collection-utils.js"; import { authorisedHandles } from "../../shared/access/authorised.js"; import { CancelByCollectionInputSchema } from "./model.js"; import type { CancelConfig } from "./model.js"; +const hasStarted = (row: RunRecord): boolean => row.startedAt !== null; + +const runsWithNoLocaleInFlight = (rows: readonly RunRecord[]): RunRecord[] => { + const inFlight = new Set(rows.filter(hasStarted).map((row) => row.handle)); + return rows.filter((row) => !inFlight.has(row.handle)); +}; + /** - * Cancels every queued job for a collection; jobs in flight are left alone. Answers 204 whether or - * not some were refused — unlike `/cancel`, which names them, because the caller here named no - * handles to be told about. + * Cancels every queued run for a collection. Answers 204 whether or not some were refused — unlike + * `/cancel`, which names them: the caller here named no handles to be told about. */ export class CancelByCollectionHandler { + private readonly config: CancelConfig; + private readonly taskRunnerFactory: TaskRunnerFactory; + private readonly runRecordStoreFactory: RunRecordStoreFactory; + constructor( - private readonly config: CancelConfig, - private readonly taskRunnerFactory: TaskRunnerFactory - ) {} + config: CancelConfig, + taskRunnerFactory: TaskRunnerFactory, + runRecordStoreFactory: RunRecordStoreFactory + ) { + this.config = config; + this.taskRunnerFactory = taskRunnerFactory; + this.runRecordStoreFactory = runRecordStoreFactory; + } async handle(req: PayloadRequest): Promise { const validationResult = CancelByCollectionInputSchema.safeParse(req.routeParams); @@ -32,30 +49,22 @@ export class CancelByCollectionHandler { return ServerResponse.badRequest("Collection not available for translation"); const runner = this.taskRunnerFactory.create(req.payload); - const rows = await runner.findByCollection(collectionSlug, { excludeCompleted: true }); - if (rows.length === 0) return ServerResponse.noContent(); - - // A job is cancellable only while none of its locales is in flight, so this excludes rather - // than selects: one running locale protects the whole job. - const running = new Set(rows.filter((row) => row.status === "running").map((row) => row.id)); - const queued = new Map( - rows - .filter((row) => !running.has(row.id)) - .map((row) => [ - row.id, - { handle: row.id, collectionSlug, collectionId: row.input.collectionId }, - ]) - ); - if (queued.size === 0) return ServerResponse.noContent(); + if (!runner.stop) return ServerResponse.notImplemented("This runner cannot stop work."); + + const live = await this.runRecordStoreFactory(req.payload).findLiveInCollection(collectionSlug); + if (live.length === 0) return ServerResponse.noContent(); + + const queued = assignmentsFromRows(runsWithNoLocaleInFlight(live)); + if (queued.length === 0) return ServerResponse.noContent(); const { authorised } = await authorisedHandles({ payload: req.payload, user: req.user, - jobs: [...queued.values()], + jobs: queued, }); if (authorised.length === 0) return ServerResponse.noContent(); - await runner.cancel(authorised); + await runner.stop(authorised); return ServerResponse.noContent(); } diff --git a/packages/payload-plugin-translator/src/server/features/cancel-by-collection/route.test.ts b/packages/payload-plugin-translator/src/server/features/cancel-by-collection/route.test.ts index 179b71a82..17e7ca805 100644 --- a/packages/payload-plugin-translator/src/server/features/cancel-by-collection/route.test.ts +++ b/packages/payload-plugin-translator/src/server/features/cancel-by-collection/route.test.ts @@ -7,6 +7,7 @@ import type { TaskRunnerFactory } from "../../modules/task-runner/index.js"; import { createCancelByCollectionRoute } from "./route.js"; import type { CancelConfig } from "./model.js"; import { AnyAccessGuard } from "../../shared/access/AnyAccessGuard.js"; +import { storeDouble } from "../../modules/runs/__tests__/fixtures/runRecordStore.js"; const OPEN_ON_PURPOSE = new AnyAccessGuard(); // Route-contract test: pin path + method + access-guard wiring so the Phase 1 @@ -15,9 +16,16 @@ const OPEN_ON_PURPOSE = new AnyAccessGuard(); const config: CancelConfig = { availableCollections: new Set() }; const denyAccess: AccessGuard = { check: vi.fn().mockReturnValue(false) }; +const noRuns = () => storeDouble(); + describe("createCancelByCollectionRoute (contract)", () => { it("registers DELETE at {basePath}/cancel-by-collection/:collection_slug with the default basePath", () => { - const endpoint = createCancelByCollectionRoute(config, { create: vi.fn() }, OPEN_ON_PURPOSE); + const endpoint = createCancelByCollectionRoute( + config, + { create: vi.fn(), capabilities: { stop: true, run: true } }, + noRuns, + OPEN_ON_PURPOSE + ); expect(endpoint.path).toBe("/translate/cancel-by-collection/:collection_slug"); expect(endpoint.method).toBe("delete"); }); @@ -25,7 +33,8 @@ describe("createCancelByCollectionRoute (contract)", () => { it("honors a custom basePath", () => { const endpoint = createCancelByCollectionRoute( config, - { create: vi.fn() }, + { create: vi.fn(), capabilities: { stop: true, run: true } }, + noRuns, OPEN_ON_PURPOSE, "/i18n" ); @@ -33,8 +42,11 @@ describe("createCancelByCollectionRoute (contract)", () => { }); it("applies the access guard — a denied request gets 403 and never reaches the handler", async () => { - const taskRunnerFactory: TaskRunnerFactory = { create: vi.fn() }; - const endpoint = createCancelByCollectionRoute(config, taskRunnerFactory, denyAccess); + const taskRunnerFactory: TaskRunnerFactory = { + create: vi.fn(), + capabilities: { stop: true, run: true }, + }; + const endpoint = createCancelByCollectionRoute(config, taskRunnerFactory, noRuns, denyAccess); const response = await endpoint.handler({} as PayloadRequest); diff --git a/packages/payload-plugin-translator/src/server/features/cancel-by-collection/route.ts b/packages/payload-plugin-translator/src/server/features/cancel-by-collection/route.ts index ab9d0df83..c5afc3e71 100644 --- a/packages/payload-plugin-translator/src/server/features/cancel-by-collection/route.ts +++ b/packages/payload-plugin-translator/src/server/features/cancel-by-collection/route.ts @@ -3,6 +3,7 @@ import type { Endpoint } from "payload"; import { withErrorHandler, withAccessCheck } from "../../shared/index.js"; import type { AccessGuard } from "../../shared/index.js"; import type { TaskRunnerFactory } from "../../modules/task-runner/index.js"; +import type { RunRecordStoreFactory } from "../../modules/runs/index.js"; import type { CancelConfig } from "./model.js"; import { CancelByCollectionHandler } from "./handler.js"; @@ -13,10 +14,11 @@ import { CancelByCollectionHandler } from "./handler.js"; export function createCancelByCollectionRoute( config: CancelConfig, taskRunnerFactory: TaskRunnerFactory, + runRecordStoreFactory: RunRecordStoreFactory, access: AccessGuard, basePath = "/translate" ): Endpoint { - const handler = new CancelByCollectionHandler(config, taskRunnerFactory); + const handler = new CancelByCollectionHandler(config, taskRunnerFactory, runRecordStoreFactory); return { path: `${basePath}/cancel-by-collection/:collection_slug`, diff --git a/packages/payload-plugin-translator/src/server/features/cancel/__tests__/handler.access.test.ts b/packages/payload-plugin-translator/src/server/features/cancel/__tests__/handler.access.test.ts index d5734b8aa..38aae277b 100644 --- a/packages/payload-plugin-translator/src/server/features/cancel/__tests__/handler.access.test.ts +++ b/packages/payload-plugin-translator/src/server/features/cancel/__tests__/handler.access.test.ts @@ -1,7 +1,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Payload, PayloadRequest } from "payload"; -import type { JobAssignment, TaskRunner } from "../../../modules/task-runner/index.js"; +import type { CollectionSlug } from "payload"; + +import type { TaskRunner } from "../../../modules/task-runner/index.js"; +import type { RunRecord } from "../../../modules/runs/index.js"; +import { runRow, storeDouble } from "../../../modules/runs/__tests__/fixtures/runRecordStore.js"; +import type { RunRecordStoreDouble } from "../../../modules/runs/__tests__/fixtures/runRecordStore.js"; vi.mock("../../../shared/access/refusedTargets.js", () => ({ refusedTargets: vi.fn().mockResolvedValue(new Set()), @@ -12,15 +17,18 @@ const { refusedTargets } = await import("../../../shared/access/refusedTargets.j const ANNA = { id: "anna", collection: "users" }; -const job = (handle: string, collectionId: string): JobAssignment => ({ - handle, - collectionSlug: "posts" as never, - collectionId, - startedBy: { id: "anna", collection: "users" }, -}); +const rowFor = (handle: string, collectionId: string): RunRecord => + runRow({ + handle, + collectionSlug: "posts" as CollectionSlug, + collectionId, + requesterId: "anna", + requesterCollection: "users", + }); describe("DELETE /cancel asks about the documents behind the handles", () => { let runner: TaskRunner; + let runs: RunRecordStoreDouble; let handler: InstanceType; const request = (ids: string[]): PayloadRequest => @@ -35,12 +43,16 @@ describe("DELETE /cancel asks about the documents behind the handles", () => { (refusedTargets as unknown as ReturnType).mockResolvedValue(new Set()); runner = { enqueue: vi.fn(), - cancel: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), run: vi.fn(), - findByCollection: vi.fn(), - assignmentsOf: vi.fn().mockResolvedValue([job("mine", "d1"), job("theirs", "d2")]), } as unknown as TaskRunner; - handler = new CancelHandler({ create: vi.fn().mockReturnValue(runner) }); + runs = storeDouble({ + findByHandles: vi.fn().mockResolvedValue([rowFor("mine", "d1"), rowFor("theirs", "d2")]), + }); + handler = new CancelHandler( + { create: vi.fn().mockReturnValue(runner), capabilities: { stop: true, run: true } }, + () => runs + ); }); it("cancels only the handles whose document the caller may change", async () => { @@ -48,7 +60,7 @@ describe("DELETE /cancel asks about the documents behind the handles", () => { await handler.handle(request(["mine", "theirs"])); - expect(runner.cancel).toHaveBeenCalledWith(["mine"]); + expect(runner.stop).toHaveBeenCalledWith(["mine"]); }); it("names the refused handles", async () => { @@ -63,31 +75,29 @@ describe("DELETE /cancel asks about the documents behind the handles", () => { const response = await handler.handle(request(["mine", "theirs"])); expect(response.status).toBe(204); - expect(runner.cancel).toHaveBeenCalledWith(["mine", "theirs"]); + expect(runner.stop).toHaveBeenCalledWith(["mine", "theirs"]); }); - it("never cancels a handle the runner could not place", async () => { - (runner.assignmentsOf as ReturnType).mockResolvedValue([job("mine", "d1")]); + it("never cancels a handle the record could not place", async () => { + runs.findByHandles.mockResolvedValue([rowFor("mine", "d1")]); await handler.handle(request(["mine", "unplaceable"])); - expect(runner.cancel).toHaveBeenCalledWith(["mine"]); + expect(runner.stop).toHaveBeenCalledWith(["mine"]); }); - it("cancels nothing when the runner cannot answer what the handles stand for", async () => { - (runner.assignmentsOf as ReturnType).mockRejectedValue( - new Error("store is down") - ); + it("cancels nothing when the record cannot answer what the handles stand for", async () => { + runs.findByHandles.mockRejectedValue(new Error("store is down")); await expect(handler.handle(request(["mine", "theirs"]))).rejects.toThrow("store is down"); - expect(runner.cancel).not.toHaveBeenCalled(); + expect(runner.stop).not.toHaveBeenCalled(); }); it("keeps two collections apart when they hold the same document id", async () => { - (runner.assignmentsOf as ReturnType).mockResolvedValue([ - { ...job("in-posts", "shared"), collectionSlug: "posts" as never }, - { ...job("in-pages", "shared"), collectionSlug: "pages" as never }, + runs.findByHandles.mockResolvedValue([ + rowFor("in-posts", "shared"), + { ...rowFor("in-pages", "shared"), collectionSlug: "pages" as CollectionSlug }, ]); (refusedTargets as unknown as ReturnType).mockImplementation( ({ collection }: { collection: string }) => @@ -96,6 +106,6 @@ describe("DELETE /cancel asks about the documents behind the handles", () => { await handler.handle(request(["in-posts", "in-pages"])); - expect(runner.cancel).toHaveBeenCalledWith(["in-posts"]); + expect(runner.stop).toHaveBeenCalledWith(["in-posts"]); }); }); diff --git a/packages/payload-plugin-translator/src/server/features/cancel/handler.test.ts b/packages/payload-plugin-translator/src/server/features/cancel/handler.test.ts index 98bfb85e5..7955c5145 100644 --- a/packages/payload-plugin-translator/src/server/features/cancel/handler.test.ts +++ b/packages/payload-plugin-translator/src/server/features/cancel/handler.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Payload, PayloadRequest } from "payload"; import type { TaskRunner, TaskRunnerFactory } from "../../modules/task-runner/index.js"; +import { runRow, storeDouble } from "../../modules/runs/__tests__/fixtures/runRecordStore.js"; +import type { RunRecordStoreDouble } from "../../modules/runs/__tests__/fixtures/runRecordStore.js"; vi.mock("../../shared/access/refusedTargets.js", () => ({ refusedTargets: vi.fn().mockResolvedValue(new Set()), @@ -13,6 +15,7 @@ describe("CancelHandler", () => { let handler: InstanceType; let mockTaskRunner: TaskRunner; let mockTaskRunnerFactory: TaskRunnerFactory; + let runs: RunRecordStoreDouble; const createMockRequest = (body: unknown): PayloadRequest => ({ @@ -23,26 +26,24 @@ describe("CancelHandler", () => { beforeEach(() => { mockTaskRunner = { enqueue: vi.fn(), - cancel: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), run: vi.fn(), - findByCollection: vi.fn(), - assignmentsOf: vi.fn().mockImplementation((handles: string[]) => - Promise.resolve( - handles.map((handle) => ({ - handle, - collectionSlug: "posts", - collectionId: "d1", - startedBy: { id: "anna", collection: "users" }, - })) - ) - ), }; mockTaskRunnerFactory = { create: vi.fn().mockReturnValue(mockTaskRunner), + capabilities: { stop: true, run: true }, }; - handler = new CancelHandler(mockTaskRunnerFactory); + runs = storeDouble({ + findByHandles: vi.fn(async (handles: string[]) => + handles.map((handle) => + runRow({ handle, requesterId: "anna", requesterCollection: "users" }) + ) + ), + }); + + handler = new CancelHandler(mockTaskRunnerFactory, () => runs); }); describe("validation", () => { @@ -83,12 +84,12 @@ describe("CancelHandler", () => { expect(response.body).toBeNull(); }); - it("calls runner.cancel with provided ids", async () => { + it("calls runner.stop with provided ids", async () => { const req = createMockRequest({ ids: ["task-1", "task-2", "task-3"] }); await handler.handle(req); - expect(mockTaskRunner.cancel).toHaveBeenCalledWith(["task-1", "task-2", "task-3"]); + expect(mockTaskRunner.stop).toHaveBeenCalledWith(["task-1", "task-2", "task-3"]); }); it("coerces numeric ids to strings (autoincrement DB ids)", async () => { @@ -100,7 +101,7 @@ describe("CancelHandler", () => { const response = await handler.handle(req); expect(response.status).toBe(204); - expect(mockTaskRunner.cancel).toHaveBeenCalledWith(["1", "2"]); + expect(mockTaskRunner.stop).toHaveBeenCalledWith(["1", "2"]); }); it("accepts a mix of numeric and string ids", async () => { @@ -109,7 +110,7 @@ describe("CancelHandler", () => { const response = await handler.handle(req); expect(response.status).toBe(204); - expect(mockTaskRunner.cancel).toHaveBeenCalledWith(["1", "uuid-abc"]); + expect(mockTaskRunner.stop).toHaveBeenCalledWith(["1", "uuid-abc"]); }); it("creates task runner with request payload", async () => { @@ -121,5 +122,36 @@ describe("CancelHandler", () => { expect(mockTaskRunnerFactory.create).toHaveBeenCalledWith(mockPayload); }); + + it("places each handle through the run record, not through the runner", async () => { + await handler.handle(createMockRequest({ ids: ["task-1", "task-2"] })); + + expect( + runs.findByHandles, + "the runner is asked to stop work, never asked what it holds" + ).toHaveBeenCalledWith(["task-1", "task-2"]); + }); + + it("cancels nothing for a handle the record does not name", async () => { + runs.findByHandles.mockResolvedValue([]); + + const response = await handler.handle(createMockRequest({ ids: ["a-stranger"] })); + + expect(response.status).toBe(204); + expect(mockTaskRunner.stop).toHaveBeenCalledWith([]); + }); + }); + describe("a runner that cannot stop work", () => { + it("is answered rather than called, although this route is not registered for one", async () => { + const cannotStop = { enqueue: vi.fn() } as unknown as TaskRunner; + const handler = new CancelHandler( + { create: vi.fn().mockReturnValue(cannotStop), capabilities: { stop: false, run: false } }, + () => runs + ); + + const response = await handler.handle(createMockRequest({ ids: ["task-1"] })); + + expect(response.status).toBe(501); + }); }); }); diff --git a/packages/payload-plugin-translator/src/server/features/cancel/handler.ts b/packages/payload-plugin-translator/src/server/features/cancel/handler.ts index 76d45274a..2e447e6b9 100644 --- a/packages/payload-plugin-translator/src/server/features/cancel/handler.ts +++ b/packages/payload-plugin-translator/src/server/features/cancel/handler.ts @@ -2,15 +2,27 @@ import type { PayloadRequest } from "payload"; import { ServerResponse } from "../../shared/index.js"; import { authorisedHandles } from "../../shared/access/authorised.js"; +import { assignmentsFromRows } from "../../modules/runs/index.js"; +import type { RunRecordStoreFactory } from "../../modules/runs/index.js"; import type { TaskRunnerFactory } from "../../modules/task-runner/index.js"; import { CancelInputSchema } from "./model.js"; /** - * Cancels and deletes translation tasks by IDs + * Stops the runs these handles name, as far as the runner can. + * + * The document behind a handle comes from the plugin's own record: Payload's `update` rule is asked + * about a document, so a handle the record does not know names nothing to ask about, and is neither + * stopped nor refused. */ export class CancelHandler { - constructor(private readonly taskRunnerFactory: TaskRunnerFactory) {} + private readonly taskRunnerFactory: TaskRunnerFactory; + private readonly runRecordStoreFactory: RunRecordStoreFactory; + + constructor(taskRunnerFactory: TaskRunnerFactory, runRecordStoreFactory: RunRecordStoreFactory) { + this.taskRunnerFactory = taskRunnerFactory; + this.runRecordStoreFactory = runRecordStoreFactory; + } async handle(req: PayloadRequest): Promise { const validationResult = CancelInputSchema.safeParse(await req.json?.()); @@ -19,15 +31,17 @@ export class CancelHandler { } const { ids } = validationResult.data; - const runner = this.taskRunnerFactory.create(req.payload); + const rows = await this.runRecordStoreFactory(req.payload).findByHandles(ids); const { authorised, refused } = await authorisedHandles({ payload: req.payload, user: req.user, - jobs: await runner.assignmentsOf(ids), + jobs: assignmentsFromRows(rows), }); - await runner.cancel(authorised); + const runner = this.taskRunnerFactory.create(req.payload); + if (!runner.stop) return ServerResponse.notImplemented("This runner cannot stop work."); + await runner.stop(authorised); if (refused.length === 0) return ServerResponse.noContent(); return ServerResponse.success({ refused }); diff --git a/packages/payload-plugin-translator/src/server/features/cancel/route.test.ts b/packages/payload-plugin-translator/src/server/features/cancel/route.test.ts index 3ec7d044e..6e91e6551 100644 --- a/packages/payload-plugin-translator/src/server/features/cancel/route.test.ts +++ b/packages/payload-plugin-translator/src/server/features/cancel/route.test.ts @@ -6,6 +6,7 @@ import type { TaskRunnerFactory } from "../../modules/task-runner/index.js"; import { createCancelRoute } from "./route.js"; import { AnyAccessGuard } from "../../shared/access/AnyAccessGuard.js"; +import { storeDouble } from "../../modules/runs/__tests__/fixtures/runRecordStore.js"; const OPEN_ON_PURPOSE = new AnyAccessGuard(); // Route-contract test: pin path + method + access-guard wiring so the Phase 1 @@ -13,21 +14,35 @@ const OPEN_ON_PURPOSE = new AnyAccessGuard(); // and the shared wrappers have their own unit tests. const denyAccess: AccessGuard = { check: vi.fn().mockReturnValue(false) }; +const noRuns = () => storeDouble(); + describe("createCancelRoute (contract)", () => { it("registers DELETE at {basePath}/cancel with the default basePath", () => { - const endpoint = createCancelRoute({ create: vi.fn() }, OPEN_ON_PURPOSE); + const endpoint = createCancelRoute( + { create: vi.fn(), capabilities: { stop: true, run: true } }, + noRuns, + OPEN_ON_PURPOSE + ); expect(endpoint.path).toBe("/translate/cancel"); expect(endpoint.method).toBe("delete"); }); it("honors a custom basePath", () => { - const endpoint = createCancelRoute({ create: vi.fn() }, OPEN_ON_PURPOSE, "/i18n"); + const endpoint = createCancelRoute( + { create: vi.fn(), capabilities: { stop: true, run: true } }, + noRuns, + OPEN_ON_PURPOSE, + "/i18n" + ); expect(endpoint.path).toBe("/i18n/cancel"); }); it("applies the access guard — a denied request gets 403 and never reaches the handler", async () => { - const taskRunnerFactory: TaskRunnerFactory = { create: vi.fn() }; - const endpoint = createCancelRoute(taskRunnerFactory, denyAccess); + const taskRunnerFactory: TaskRunnerFactory = { + create: vi.fn(), + capabilities: { stop: true, run: true }, + }; + const endpoint = createCancelRoute(taskRunnerFactory, noRuns, denyAccess); const response = await endpoint.handler({} as PayloadRequest); diff --git a/packages/payload-plugin-translator/src/server/features/cancel/route.ts b/packages/payload-plugin-translator/src/server/features/cancel/route.ts index 5ea5d9049..1c4503007 100644 --- a/packages/payload-plugin-translator/src/server/features/cancel/route.ts +++ b/packages/payload-plugin-translator/src/server/features/cancel/route.ts @@ -3,6 +3,7 @@ import type { Endpoint } from "payload"; import { withErrorHandler, withAccessCheck } from "../../shared/index.js"; import type { AccessGuard } from "../../shared/index.js"; import type { TaskRunnerFactory } from "../../modules/task-runner/index.js"; +import type { RunRecordStoreFactory } from "../../modules/runs/index.js"; import { CancelHandler } from "./handler.js"; @@ -11,10 +12,11 @@ import { CancelHandler } from "./handler.js"; */ export function createCancelRoute( taskRunnerFactory: TaskRunnerFactory, + runRecordStoreFactory: RunRecordStoreFactory, access: AccessGuard, basePath = "/translate" ): Endpoint { - const handler = new CancelHandler(taskRunnerFactory); + const handler = new CancelHandler(taskRunnerFactory, runRecordStoreFactory); return { path: `${basePath}/cancel`, diff --git a/packages/payload-plugin-translator/src/server/features/createTranslationRoutes.test.ts b/packages/payload-plugin-translator/src/server/features/createTranslationRoutes.test.ts index 517ab99b8..f756bbbe8 100644 --- a/packages/payload-plugin-translator/src/server/features/createTranslationRoutes.test.ts +++ b/packages/payload-plugin-translator/src/server/features/createTranslationRoutes.test.ts @@ -4,6 +4,7 @@ import { resolveSettings } from "../../settings.js"; import { createTranslationRoutes } from "./createTranslationRoutes.js"; import type { TranslationRoutesDeps } from "./createTranslationRoutes.js"; import { AnyAccessGuard } from "../shared/access/AnyAccessGuard.js"; +import { storeDouble } from "../modules/runs/__tests__/fixtures/runRecordStore.js"; const OPEN_ON_PURPOSE = new AnyAccessGuard(); // Bundle-contract test: pins that the shared route bundle stays complete and @@ -11,7 +12,8 @@ const OPEN_ON_PURPOSE = new AnyAccessGuard(); // the doc + collection levels (deduped), so "all endpoints, right paths/methods" // must not drift. Each route's own wiring is pinned by its sibling route.test.ts. const deps = (basePath?: string): TranslationRoutesDeps => ({ - taskRunnerFactory: { create: vi.fn() }, + taskRunnerFactory: { create: vi.fn(), capabilities: { stop: true, run: true } }, + runRecordStoreFactory: () => storeDouble(), collectionConfig: { availableCollections: new Set() }, access: OPEN_ON_PURPOSE, settings: resolveSettings({ basePath }), diff --git a/packages/payload-plugin-translator/src/server/features/createTranslationRoutes.ts b/packages/payload-plugin-translator/src/server/features/createTranslationRoutes.ts index ce12a84a8..c3dc89487 100644 --- a/packages/payload-plugin-translator/src/server/features/createTranslationRoutes.ts +++ b/packages/payload-plugin-translator/src/server/features/createTranslationRoutes.ts @@ -11,7 +11,7 @@ import { createGetCollectionStatusRoute } from "./get-collection-status/index.js export type TranslationRoutesDeps = Pick< ConfigTimeContext, - "taskRunnerFactory" | "access" | "settings" + "taskRunnerFactory" | "runRecordStoreFactory" | "access" | "settings" > & { /** Collections the plugin manages — gates which slugs the routes accept. */ collectionConfig: { availableCollections: Set }; @@ -19,20 +19,41 @@ export type TranslationRoutesDeps = Pick< export function createTranslationRoutes({ taskRunnerFactory, + runRecordStoreFactory, collectionConfig, access, settings, }: TranslationRoutesDeps): Endpoint[] { const { basePath, failedWindowMs } = settings; + const { stop, run } = taskRunnerFactory.capabilities; + return [ - createEnqueueRoute(taskRunnerFactory, collectionConfig, access, basePath), - createRunRoute(taskRunnerFactory, access, basePath), - createCancelRoute(taskRunnerFactory, access, basePath), - createCancelByCollectionRoute(collectionConfig, taskRunnerFactory, access, basePath), - createGetDocumentStatusRoute(collectionConfig, taskRunnerFactory, access, basePath), + createEnqueueRoute( + taskRunnerFactory, + runRecordStoreFactory, + collectionConfig, + access, + basePath + ), + ...(run ? [createRunRoute(taskRunnerFactory, runRecordStoreFactory, access, basePath)] : []), + ...(stop + ? [createCancelRoute(taskRunnerFactory, runRecordStoreFactory, access, basePath)] + : []), + ...(stop + ? [ + createCancelByCollectionRoute( + collectionConfig, + taskRunnerFactory, + runRecordStoreFactory, + access, + basePath + ), + ] + : []), + createGetDocumentStatusRoute(collectionConfig, runRecordStoreFactory, access, basePath), createGetCollectionStatusRoute( { ...collectionConfig, failedWindowMs }, - taskRunnerFactory, + runRecordStoreFactory, access, basePath ), diff --git a/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.access.test.ts b/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.access.test.ts index 37f9e14c6..4984402b6 100644 --- a/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.access.test.ts +++ b/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.access.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Payload, PayloadRequest, CollectionSlug } from "payload"; import type { TaskRunnerFactory, TaskRunner } from "../../../modules/task-runner/index.js"; +import { storeDouble } from "../../../modules/runs/__tests__/fixtures/runRecordStore.js"; vi.mock("../../_lib/collection-utils.js", () => ({ isCollectionAvailable: vi.fn((slug: string, available: Set) => @@ -59,15 +60,14 @@ describe("enqueue asks about every document it was named", () => { (refusedTargets as unknown as ReturnType).mockResolvedValue(new Set()); runner = { enqueue: vi.fn().mockResolvedValue(undefined), - cancel: vi.fn(), + stop: vi.fn(), run: vi.fn(), - assignmentsOf: vi.fn().mockResolvedValue([]), - findByCollection: vi.fn(), }; - factory = { create: vi.fn().mockReturnValue(runner) }; + factory = { create: vi.fn().mockReturnValue(runner), capabilities: { stop: true, run: true } }; handler = new EnqueueTranslationHandler( { availableCollections: new Set(["posts"] as CollectionSlug[]) }, - factory + factory, + () => storeDouble() ); }); diff --git a/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.blocking.test.ts b/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.blocking.test.ts new file mode 100644 index 000000000..b3662ce84 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.blocking.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { CollectionSlug, PayloadRequest } from "payload"; + +import { EnqueueTranslationHandler } from "../handler.js"; +import type { EnqueueConfig } from "../model.js"; +import type { TaskRunner, TaskRunnerFactory } from "../../../modules/task-runner/index.js"; +import { runRow, storeDouble } from "../../../modules/runs/__tests__/fixtures/runRecordStore.js"; +import type { RunRecordStoreDouble } from "../../../modules/runs/__tests__/fixtures/runRecordStore.js"; + +vi.mock("../../../shared/access/refusedTargets.js", () => ({ + refusedTargets: vi.fn().mockResolvedValue(new Set()), +})); + +const { refusedTargets } = await import("../../../shared/access/refusedTargets.js"); +const refuse = refusedTargets as unknown as ReturnType; + +const everyDocumentIsVisible = () => + vi.fn(async (args: { where?: { id?: { in?: string[] } } }) => ({ + docs: (args.where?.id?.in ?? []).map((id) => ({ id })), + })); + +describe("one run per document, decided before the runner is asked", () => { + let handler: EnqueueTranslationHandler; + let runner: TaskRunner; + let runs: RunRecordStoreDouble; + let config: EnqueueConfig; + + const request = (body: Record): PayloadRequest => + ({ + payload: { + config: { localization: { locales: ["en", "de", "fr"] } }, + logger: { warn: vi.fn() }, + find: everyDocumentIsVisible(), + }, + user: { id: "anna", collection: "users" }, + json: vi.fn().mockResolvedValue(body), + }) as unknown as PayloadRequest; + + const ask = (ids: string[], over: Record = {}) => + request({ + source_lng: "en", + target_lng: ["de"], + collection_slug: "posts", + collection_id: ids, + ...over, + }); + + const answered = async (req: PayloadRequest) => (await handler.handle(req)).json(); + const queuedWork = () => (runner.enqueue as unknown as ReturnType).mock.calls[0][0]; + + beforeEach(() => { + vi.clearAllMocks(); + refuse.mockResolvedValue(new Set()); + runner = { + enqueue: vi.fn().mockResolvedValue([]), + cancel: vi.fn(), + run: vi.fn(), + } as unknown as TaskRunner; + runs = storeDouble(); + config = { availableCollections: new Set(["posts"] as CollectionSlug[]) }; + handler = new EnqueueTranslationHandler( + config, + { create: vi.fn().mockReturnValue(runner) } as unknown as TaskRunnerFactory, + () => runs + ); + }); + + it("queues nothing for a document that already has a run under way", async () => { + runs.findLiveForDocuments.mockResolvedValue([runRow({ collectionId: "d1" })]); + + const body = await answered(ask(["d1"])); + + expect(queuedWork()).toEqual([]); + expect(body.data.queued).toBe(0); + expect(body.data.busy).toEqual(["d1"]); + }); + + it("queues the free document alongside, and names only the busy one", async () => { + runs.findLiveForDocuments.mockResolvedValue([runRow({ collectionId: "busy" })]); + + const body = await answered(ask(["busy", "free"])); + + expect(queuedWork().map((task: { collectionId: string }) => task.collectionId)).toEqual([ + "free", + ]); + expect(body.data.busy).toEqual(["busy"]); + expect(body.data.queued).toBe(1); + }); + + it("keeps being busy apart from being refused, because they mean opposite things", async () => { + refuse.mockResolvedValue(new Set(["forbidden"])); + runs.findLiveForDocuments.mockResolvedValue([runRow({ collectionId: "busy" })]); + + const body = await answered(ask(["forbidden", "busy", "free"])); + + expect(body.data.refused).toEqual(["forbidden"]); + expect(body.data.busy).toEqual(["busy"]); + }); + + it("says nothing about busy documents when there are none", async () => { + const body = await answered(ask(["free"])); + + expect(body.data).not.toHaveProperty("busy"); + }); + + it("asks the record only about the documents the caller may change", async () => { + refuse.mockResolvedValue(new Set(["forbidden"])); + + await answered(ask(["forbidden", "free"])); + + expect( + runs.findLiveForDocuments, + "a document the caller may not touch is theirs to be refused about, not told is busy" + ).toHaveBeenCalledWith("posts", ["free"]); + }); + + it("asks the record about the whole collection when the request is select_all", async () => { + await answered(ask([], { select_all: true, collection_id: ["ignored"] })); + + expect(runs.findLiveInCollection).toHaveBeenCalledWith("posts"); + expect(runs.findLiveForDocuments).not.toHaveBeenCalled(); + }); + + it("frees a document whose runs have all settled", async () => { + runs.findLiveForDocuments.mockResolvedValue([]); + + const body = await answered(ask(["d1"])); + + expect(body.data.queued).toBe(1); + expect(body.data).not.toHaveProperty("busy"); + }); +}); diff --git a/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.identity.test.ts b/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.identity.test.ts index a63bda53d..83f7cec7f 100644 --- a/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.identity.test.ts +++ b/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.identity.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { CollectionSlug, PayloadRequest } from "payload"; +import { storeDouble } from "../../../modules/runs/__tests__/fixtures/runRecordStore.js"; vi.mock("../../../shared/access/refusedTargets.js", () => ({ refusedTargets: vi.fn().mockResolvedValue(new Set()), @@ -21,7 +22,8 @@ function setup(user: unknown) { const enqueue = vi.fn().mockResolvedValue(undefined); const handler = new EnqueueTranslationHandler( { availableCollections: new Set(["posts" as CollectionSlug]) } as never, - { create: vi.fn().mockReturnValue({ enqueue }) } as never + { create: vi.fn().mockReturnValue({ enqueue }) } as never, + () => storeDouble() ); const req = { diff --git a/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.test.ts b/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.test.ts index c5e503f1d..e1676b912 100644 --- a/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.test.ts +++ b/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.test.ts @@ -3,6 +3,7 @@ import type { Payload, PayloadRequest, CollectionSlug } from "payload"; import { EnqueueTranslationHandler } from "../handler.js"; import type { EnqueueConfig } from "../model.js"; import type { TaskRunnerFactory, TaskRunner } from "../../../modules/task-runner/index.js"; +import { storeDouble } from "../../../modules/runs/__tests__/fixtures/runRecordStore.js"; vi.mock("../../_lib/collection-utils.js", () => ({ isCollectionAvailable: vi.fn((slug: string, available: Set) => @@ -50,21 +51,20 @@ describe("EnqueueTranslationHandler", () => { mockTaskRunner = { enqueue: vi.fn().mockResolvedValue(undefined), - cancel: vi.fn(), + stop: vi.fn(), run: vi.fn(), - assignmentsOf: vi.fn().mockResolvedValue([]), - findByCollection: vi.fn(), }; mockTaskRunnerFactory = { create: vi.fn().mockReturnValue(mockTaskRunner), + capabilities: { stop: true, run: true }, }; config = { availableCollections: new Set(["posts", "pages"] as CollectionSlug[]), }; - handler = new EnqueueTranslationHandler(config, mockTaskRunnerFactory); + handler = new EnqueueTranslationHandler(config, mockTaskRunnerFactory, () => storeDouble()); }); describe("locale validation", () => { diff --git a/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.unnameable.test.ts b/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.unnameable.test.ts index ca8fca6ce..bc74dc03c 100644 --- a/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.unnameable.test.ts +++ b/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.unnameable.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { CollectionSlug, Payload, PayloadRequest } from "payload"; import type { TaskRunner, TaskRunnerFactory } from "../../../modules/task-runner/index.js"; +import { storeDouble } from "../../../modules/runs/__tests__/fixtures/runRecordStore.js"; vi.mock("../../_lib/collection-utils.js", () => ({ isCollectionAvailable: vi.fn((slug: string, available: Set) => @@ -50,14 +51,14 @@ describe("POST /enqueue and an actor the project cannot name", () => { vi.clearAllMocks(); runner = { enqueue: vi.fn().mockResolvedValue(undefined), - cancel: vi.fn(), + stop: vi.fn(), run: vi.fn(), - findByCollection: vi.fn(), } as unknown as TaskRunner; - factory = { create: vi.fn().mockReturnValue(runner) }; + factory = { create: vi.fn().mockReturnValue(runner), capabilities: { stop: true, run: true } }; handler = new EnqueueTranslationHandler( { availableCollections: new Set(["posts"] as CollectionSlug[]) }, - factory + factory, + () => storeDouble() ); }); diff --git a/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.unnameableIsRefusedFirst.test.ts b/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.unnameableIsRefusedFirst.test.ts index b0f483e0f..3f3ab0de9 100644 --- a/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.unnameableIsRefusedFirst.test.ts +++ b/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/handler.unnameableIsRefusedFirst.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Payload, PayloadRequest, CollectionSlug } from "payload"; +import { storeDouble } from "../../../modules/runs/__tests__/fixtures/runRecordStore.js"; /* * `handler.unnameable.test.ts` mocks `authorisedWork` away, so it cannot see *when* the refusal @@ -52,7 +53,11 @@ describe("POST /enqueue refuses an unnameable actor before asking about anything askedAbout.length = 0; handler = new EnqueueTranslationHandler( { availableCollections: new Set(["posts"] as CollectionSlug[]) }, - { create: vi.fn().mockReturnValue({ enqueue: vi.fn().mockResolvedValue(undefined) }) } + { + create: vi.fn().mockReturnValue({ enqueue: vi.fn().mockResolvedValue([]) }), + capabilities: { stop: true, run: true }, + }, + () => storeDouble() ); }); diff --git a/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/route.test.ts b/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/route.test.ts index 822151567..c084e20a7 100644 --- a/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/route.test.ts +++ b/packages/payload-plugin-translator/src/server/features/enqueue-translation/__tests__/route.test.ts @@ -7,6 +7,7 @@ import type { TaskRunnerFactory } from "../../../modules/task-runner/index.js"; import { createEnqueueRoute } from "../route.js"; import type { EnqueueConfig } from "../model.js"; import { AnyAccessGuard } from "../../../shared/access/AnyAccessGuard.js"; +import { storeDouble } from "../../../modules/runs/__tests__/fixtures/runRecordStore.js"; const OPEN_ON_PURPOSE = new AnyAccessGuard(); // Route-contract tests: pin path + method + access-guard wiring so the Phase 1 @@ -18,21 +19,37 @@ const config: EnqueueConfig = { }; const denyAccess: AccessGuard = { check: vi.fn().mockReturnValue(false) }; +const noRuns = () => storeDouble(); + describe("createEnqueueRoute (contract)", () => { it("registers POST at {basePath}/enqueue with the default basePath", () => { - const endpoint = createEnqueueRoute({ create: vi.fn() }, config, OPEN_ON_PURPOSE); + const endpoint = createEnqueueRoute( + { create: vi.fn(), capabilities: { stop: true, run: true } }, + noRuns, + config, + OPEN_ON_PURPOSE + ); expect(endpoint.path).toBe("/translate/enqueue"); expect(endpoint.method).toBe("post"); }); it("honors a custom basePath", () => { - const endpoint = createEnqueueRoute({ create: vi.fn() }, config, OPEN_ON_PURPOSE, "/i18n"); + const endpoint = createEnqueueRoute( + { create: vi.fn(), capabilities: { stop: true, run: true } }, + noRuns, + config, + OPEN_ON_PURPOSE, + "/i18n" + ); expect(endpoint.path).toBe("/i18n/enqueue"); }); it("applies the access guard — a denied request gets 403 and never reaches the handler", async () => { - const taskRunnerFactory: TaskRunnerFactory = { create: vi.fn() }; - const endpoint = createEnqueueRoute(taskRunnerFactory, config, denyAccess); + const taskRunnerFactory: TaskRunnerFactory = { + create: vi.fn(), + capabilities: { stop: true, run: true }, + }; + const endpoint = createEnqueueRoute(taskRunnerFactory, noRuns, config, denyAccess); const response = await endpoint.handler({} as PayloadRequest); diff --git a/packages/payload-plugin-translator/src/server/features/enqueue-translation/handler.ts b/packages/payload-plugin-translator/src/server/features/enqueue-translation/handler.ts index 69e1ca8bb..ca4e33490 100644 --- a/packages/payload-plugin-translator/src/server/features/enqueue-translation/handler.ts +++ b/packages/payload-plugin-translator/src/server/features/enqueue-translation/handler.ts @@ -3,22 +3,35 @@ import type { PayloadRequest } from "payload"; import { ServerResponse } from "../../shared/index.js"; import { authCollectionsOf, identityOf, scopeOf } from "../../shared/payload/identityOf.js"; import type { TaskInput, TaskRunnerFactory } from "../../modules/task-runner/index.js"; +import type { RunRecordStoreFactory } from "../../modules/runs/index.js"; +import { admitWork } from "../../../core/domain/runs/index.js"; import { isCollectionAvailable } from "../_lib/collection-utils.js"; import { getAllCollectionIds } from "../../shared/payload/collections.js"; import { authorisedByTheReadFilter, authorisedWork } from "../../shared/access/authorised.js"; +import type { AuthorisedWork } from "../../shared/access/authorised.js"; import { Locales } from "../../../core/domain/locales/index.js"; import { EnqueueInputSchema, toAssignmentOutputs } from "./model.js"; import type { EnqueueConfig } from "./model.js"; +const documentsIn = (work: readonly AuthorisedWork[]): string[] => [ + ...new Set(work.map((item) => item.collectionId)), +]; + export class EnqueueTranslationHandler { private readonly config: EnqueueConfig; private readonly taskRunnerFactory: TaskRunnerFactory; + private readonly runRecordStoreFactory: RunRecordStoreFactory; - constructor(config: EnqueueConfig, taskRunnerFactory: TaskRunnerFactory) { + constructor( + config: EnqueueConfig, + taskRunnerFactory: TaskRunnerFactory, + runRecordStoreFactory: RunRecordStoreFactory + ) { this.config = config; this.taskRunnerFactory = taskRunnerFactory; + this.runRecordStoreFactory = runRecordStoreFactory; } async handle(req: PayloadRequest): Promise { @@ -89,11 +102,10 @@ export class EnqueueTranslationHandler { })) ); - // Before the ask, not after: Payload fills a missing `user.collection` with the admin one - // (`createLocalReq`), so asking first evaluates the host's rule as whoever shares that id there. const identity = identityOf(req, authCollectionsOf(req.payload), req.payload.logger); if (identity.kind === "unnameable") return ServerResponse.forbidden(); + const runs = this.runRecordStoreFactory(req.payload); const { authorised, refused } = select_all ? { authorised: authorisedByTheReadFilter( @@ -107,13 +119,19 @@ export class EnqueueTranslationHandler { tasks: workFor(collection_id), }); + const inFlight = select_all + ? await runs.findLiveInCollection(collectionSlug) + : await runs.findLiveForDocuments(collectionSlug, documentsIn(authorised)); + const { admitted, busyDocumentIds: busy } = admitWork({ work: authorised, inFlight }); + const runner = this.taskRunnerFactory.create(req.payload); - const assigned = await runner.enqueue(authorised, scopeOf(identity)); + const assigned = await runner.enqueue(admitted, scopeOf(identity)); return ServerResponse.success({ success: true, - queued: authorised.length, + queued: admitted.length, ...(refused.length > 0 ? { refused } : {}), + ...(busy.length > 0 ? { busy } : {}), ...(assigned ? { assignments: toAssignmentOutputs(assigned) } : {}), }); } diff --git a/packages/payload-plugin-translator/src/server/features/enqueue-translation/route.ts b/packages/payload-plugin-translator/src/server/features/enqueue-translation/route.ts index 1041c2b8e..95d293f73 100644 --- a/packages/payload-plugin-translator/src/server/features/enqueue-translation/route.ts +++ b/packages/payload-plugin-translator/src/server/features/enqueue-translation/route.ts @@ -3,6 +3,7 @@ import type { Endpoint } from "payload"; import { withErrorHandler, withAccessCheck } from "../../shared/index.js"; import type { AccessGuard } from "../../shared/index.js"; import type { TaskRunnerFactory } from "../../modules/task-runner/index.js"; +import type { RunRecordStoreFactory } from "../../modules/runs/index.js"; import type { EnqueueConfig } from "./model.js"; import { EnqueueTranslationHandler } from "./handler.js"; @@ -12,11 +13,12 @@ import { EnqueueTranslationHandler } from "./handler.js"; */ export function createEnqueueRoute( taskRunnerFactory: TaskRunnerFactory, + runRecordStoreFactory: RunRecordStoreFactory, config: EnqueueConfig, access: AccessGuard, basePath = "/translate" ): Endpoint { - const handler = new EnqueueTranslationHandler(config, taskRunnerFactory); + const handler = new EnqueueTranslationHandler(config, taskRunnerFactory, runRecordStoreFactory); return { path: `${basePath}/enqueue`, diff --git a/packages/payload-plugin-translator/src/server/features/get-collection-status/handler.test.ts b/packages/payload-plugin-translator/src/server/features/get-collection-status/handler.test.ts index ffbf9a4f1..404f24b6b 100644 --- a/packages/payload-plugin-translator/src/server/features/get-collection-status/handler.test.ts +++ b/packages/payload-plugin-translator/src/server/features/get-collection-status/handler.test.ts @@ -1,8 +1,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Payload, PayloadRequest, CollectionSlug } from "payload"; + import { GetCollectionStatusHandler } from "./handler.js"; import type { GetCollectionStatusConfig } from "./model.js"; -import type { TaskRunnerFactory, TaskRunner, Task } from "../../modules/task-runner/index.js"; +import { runRow, storeDouble } from "../../modules/runs/__tests__/fixtures/runRecordStore.js"; +import type { RunRecordStoreDouble } from "../../modules/runs/__tests__/fixtures/runRecordStore.js"; + +const THREE_DAYS = 259_200_000; const everyDocumentIsVisible = () => vi.fn(async (args: { where?: { id?: { in?: string[] } } }) => ({ @@ -11,110 +15,71 @@ const everyDocumentIsVisible = () => describe("GetCollectionStatusHandler", () => { let handler: GetCollectionStatusHandler; - let mockTaskRunner: TaskRunner; - let mockTaskRunnerFactory: TaskRunnerFactory; + let runs: RunRecordStoreDouble; + let openTheRecord: ReturnType; let config: GetCollectionStatusConfig; - const createMockTask = ({ - targetLng = "de", - collectionId = "doc-123", - ...overrides - }: Partial & { targetLng?: string; collectionId?: string } = {}): Task => ({ - id: "task-123", - status: "completed", - input: { - collectionSlug: "posts" as CollectionSlug, - collectionId, - sourceLng: "en", - targetLng, - publishOnTranslation: false, - }, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T01:00:00Z", - completedAt: "2024-01-01T01:00:00Z", - cancelled: false, - ...overrides, - }); - - const createMockRequest = (params: Record = {}): PayloadRequest => + const request = (params: Record = {}): PayloadRequest => ({ payload: { find: everyDocumentIsVisible() } as unknown as Payload, routeParams: params, }) as unknown as PayloadRequest; - beforeEach(() => { - mockTaskRunner = { - enqueue: vi.fn(), - cancel: vi.fn(), - run: vi.fn(), - assignmentsOf: vi.fn().mockResolvedValue([]), - findByCollection: vi.fn().mockResolvedValue([]), - }; - - mockTaskRunnerFactory = { - create: vi.fn().mockReturnValue(mockTaskRunner), - }; + const answered = async (req: PayloadRequest) => { + const response = await handler.handle(req); + return { status: response.status, body: await response.json() }; + }; + beforeEach(() => { + runs = storeDouble(); + openTheRecord = vi.fn(() => runs); config = { availableCollections: new Set(["posts", "pages"] as CollectionSlug[]), - failedWindowMs: 259_200_000, + failedWindowMs: THREE_DAYS, }; - - handler = new GetCollectionStatusHandler(config, mockTaskRunnerFactory); + handler = new GetCollectionStatusHandler(config, openTheRecord); }); describe("validation", () => { - it("returns validation error for missing collection_slug", async () => { - const req = createMockRequest({}); - - const response = await handler.handle(req); + it("refuses a request naming no collection", async () => { + const { status, body } = await answered(request({})); - expect(response.status).toBe(400); - const body = await response.json(); + expect(status).toBe(400); expect(body.message).toBe("Validation error"); }); - it("returns validation error for empty collection_slug", async () => { - const req = createMockRequest({ collection_slug: "" }); - - const response = await handler.handle(req); - - expect(response.status).toBe(400); - }); - }); - - describe("collection availability", () => { - it("returns bad request for unavailable collection", async () => { - const req = createMockRequest({ collection_slug: "users" }); + it("refuses a collection the plugin does not translate", async () => { + const { status, body } = await answered(request({ collection_slug: "users" })); - const response = await handler.handle(req); - - expect(response.status).toBe(400); - const body = await response.json(); + expect(status).toBe(400); expect(body.message).toBe("Collection not available for translation"); }); }); - describe("success responses", () => { - it("returns empty docs array when no tasks exist", async () => { - (mockTaskRunner.findByCollection as ReturnType).mockResolvedValue([]); + describe("what it reads", () => { + it("asks for the work in flight and, separately, the failures inside the window", async () => { + const before = Date.now(); - const req = createMockRequest({ collection_slug: "posts" }); - const response = await handler.handle(req); + await answered(request({ collection_slug: "pages" })); - expect(response.status).toBe(200); - const body = await response.json(); - expect(body.data).toEqual({ docs: [], failed_window_ms: 259_200_000 }); + expect(runs.findLiveInCollection).toHaveBeenCalledWith("pages"); + const [slug, since] = runs.findRecentlyFailedInCollection.mock.calls[0]; + expect(slug).toBe("pages"); + expect( + (since as Date).getTime(), + "the window reaches back exactly as far as the setting says" + ).toBeGreaterThanOrEqual(before - THREE_DAYS - 1000); + expect((since as Date).getTime()).toBeLessThanOrEqual(Date.now() - THREE_DAYS + 1000); }); it("asks which documents are visible once, not once per row", async () => { const find = everyDocumentIsVisible(); - const req = createMockRequest({ collection_slug: "posts" }); + const req = request({ collection_slug: "posts" }); (req as unknown as { payload: unknown }).payload = { find }; - (mockTaskRunner.findByCollection as ReturnType).mockResolvedValue([ - createMockTask({ id: "t1" }), - createMockTask({ id: "t2" }), - createMockTask({ id: "t3" }), + runs.findLiveInCollection.mockResolvedValue([ + runRow({ handle: "r1" }), + runRow({ handle: "r2" }), + runRow({ handle: "r3" }), ]); await handler.handle(req); @@ -124,55 +89,64 @@ describe("GetCollectionStatusHandler", () => { "Payload folds a read rule into the query, so three rows cost one question, not three" ).toHaveBeenCalledTimes(1); }); + }); + + describe("what it answers", () => { + it("answers with nothing when the collection has nothing to report", async () => { + const { status, body } = await answered(request({ collection_slug: "posts" })); + + expect(status).toBe(200); + expect(body.data).toEqual({ docs: [], failed_window_ms: THREE_DAYS }); + }); - it("returns task statuses for all documents in collection", async () => { - const tasks = [ - createMockTask({ id: "run-1", status: "completed", targetLng: "de" }), - createMockTask({ id: "run-1", status: "pending", targetLng: "fr" }), - createMockTask({ id: "run-2", status: "running", targetLng: "de", collectionId: "doc-9" }), - ]; - (mockTaskRunner.findByCollection as ReturnType).mockResolvedValue(tasks); + it("names the run, the document and the locale, one entry per locale", async () => { + runs.findLiveInCollection.mockResolvedValue([ + runRow({ handle: "run-1", targetLng: "de", startedAt: "2026-10-27T10:01:00.000Z" }), + runRow({ handle: "run-1", targetLng: "fr" }), + runRow({ handle: "run-2", collectionId: "doc-9", targetLng: "de" }), + ]); - const req = createMockRequest({ collection_slug: "posts" }); - const response = await handler.handle(req); + const { body } = await answered(request({ collection_slug: "posts" })); - expect(response.status).toBe(200); - const body = await response.json(); expect( body.data.docs, "two of these share a run, so the handle alone cannot tell them apart" ).toEqual([ - { id: "run-1", status: "completed", collection_id: "doc-123", target_lng: "de" }, - { id: "run-1", status: "pending", collection_id: "doc-123", target_lng: "fr" }, - { id: "run-2", status: "running", collection_id: "doc-9", target_lng: "de" }, + { id: "run-1", status: "running", collection_id: "doc-1", target_lng: "de" }, + { id: "run-1", status: "pending", collection_id: "doc-1", target_lng: "fr" }, + { id: "run-2", status: "pending", collection_id: "doc-9", target_lng: "de" }, ]); }); - it("calls findByCollection with correct collection slug", async () => { - const req = createMockRequest({ collection_slug: "pages" }); + it("lists a recent failure beside the work in flight", async () => { + runs.findLiveInCollection.mockResolvedValue([runRow({ handle: "live" })]); + runs.findRecentlyFailedInCollection.mockResolvedValue([ + runRow({ + handle: "broke", + targetLng: "fr", + startedAt: "2026-10-27T10:01:00.000Z", + settledAt: "2026-10-27T10:02:00.000Z", + outcome: "failed", + error: "it broke", + }), + ]); - await handler.handle(req); + const { body } = await answered(request({ collection_slug: "posts" })); - expect( - mockTaskRunner.findByCollection, - "the panel asks what the collection is doing: work in flight, plus failures recent enough to act on" - ).toHaveBeenCalledWith("pages", { - excludeCompleted: true, - excludeGivenUpBefore: expect.any(Date), - }); + expect(body.data.docs.map((d: { status: string }) => d.status)).toEqual([ + "pending", + "failed", + ]); }); - it("creates task runner with request payload", async () => { - const mockPayload = { - collections: {}, - find: everyDocumentIsVisible(), - } as unknown as Payload; - const req = createMockRequest({ collection_slug: "posts" }); - (req as any).payload = mockPayload; + it("hides a document the caller may not read", async () => { + const req = request({ collection_slug: "posts" }); + (req.payload as unknown as { find: unknown }).find = vi.fn(async () => ({ docs: [] })); + runs.findLiveInCollection.mockResolvedValue([runRow()]); - await handler.handle(req); + const { body } = await answered(req); - expect(mockTaskRunnerFactory.create).toHaveBeenCalledWith(mockPayload); + expect(body.data.docs).toEqual([]); }); }); }); diff --git a/packages/payload-plugin-translator/src/server/features/get-collection-status/handler.ts b/packages/payload-plugin-translator/src/server/features/get-collection-status/handler.ts index 1e4375225..7e661e241 100644 --- a/packages/payload-plugin-translator/src/server/features/get-collection-status/handler.ts +++ b/packages/payload-plugin-translator/src/server/features/get-collection-status/handler.ts @@ -1,7 +1,8 @@ import type { PayloadRequest } from "payload"; import { ServerResponse } from "../../shared/index.js"; -import type { TaskRunnerFactory } from "../../modules/task-runner/index.js"; +import type { RunRecordStoreFactory } from "../../modules/runs/index.js"; +import { reportedRuns } from "../../modules/runs/index.js"; import { isCollectionAvailable } from "../_lib/collection-utils.js"; import { visibleIds } from "../../shared/payload/collections.js"; @@ -11,11 +12,11 @@ import type { GetCollectionStatusConfig } from "./model.js"; /** What a collection is doing: runs in flight, plus failures inside the configured window. */ export class GetCollectionStatusHandler { private readonly config: GetCollectionStatusConfig; - private readonly taskRunnerFactory: TaskRunnerFactory; + private readonly runRecordStoreFactory: RunRecordStoreFactory; - constructor(config: GetCollectionStatusConfig, taskRunnerFactory: TaskRunnerFactory) { + constructor(config: GetCollectionStatusConfig, runRecordStoreFactory: RunRecordStoreFactory) { this.config = config; - this.taskRunnerFactory = taskRunnerFactory; + this.runRecordStoreFactory = runRecordStoreFactory; } async handle(req: PayloadRequest): Promise { @@ -34,23 +35,29 @@ export class GetCollectionStatusHandler { } const { failedWindowMs } = this.config; - const runner = this.taskRunnerFactory.create(req.payload); - const tasks = await runner.findByCollection(collectionSlug, { - excludeCompleted: true, - excludeGivenUpBefore: new Date(Date.now() - failedWindowMs), - }); - const ids = tasks.map((task) => String(task.input.collectionId)); - const visible = await visibleIds(req.payload, collectionSlug, ids, req.user); + const store = this.runRecordStoreFactory(req.payload); + const [live, failed] = await Promise.all([ + store.findLiveInCollection(collectionSlug), + store.findRecentlyFailedInCollection(collectionSlug, new Date(Date.now() - failedWindowMs)), + ]); + const shown = reportedRuns([...live, ...failed]); + + const visible = await visibleIds( + req.payload, + collectionSlug, + shown.map(({ row }) => row.collectionId), + req.user + ); return ServerResponse.success({ failed_window_ms: failedWindowMs, - docs: tasks - .filter((task) => visible.has(String(task.input.collectionId))) - .map((task) => ({ - id: task.id, - status: task.status, - collection_id: String(task.input.collectionId), - target_lng: task.input.targetLng, + docs: shown + .filter(({ row }) => visible.has(row.collectionId)) + .map(({ row, status }) => ({ + id: row.handle, + status, + collection_id: row.collectionId, + target_lng: row.targetLng, })), }); } diff --git a/packages/payload-plugin-translator/src/server/features/get-collection-status/route.test.ts b/packages/payload-plugin-translator/src/server/features/get-collection-status/route.test.ts index ecfff6357..d5d24b733 100644 --- a/packages/payload-plugin-translator/src/server/features/get-collection-status/route.test.ts +++ b/packages/payload-plugin-translator/src/server/features/get-collection-status/route.test.ts @@ -2,11 +2,11 @@ import { describe, it, expect, vi } from "vitest"; import type { PayloadRequest } from "payload"; import type { AccessGuard } from "../../shared/index.js"; -import type { TaskRunnerFactory } from "../../modules/task-runner/index.js"; import { createGetCollectionStatusRoute } from "./route.js"; import type { GetCollectionStatusConfig } from "./model.js"; import { AnyAccessGuard } from "../../shared/access/AnyAccessGuard.js"; +import { storeDouble } from "../../modules/runs/__tests__/fixtures/runRecordStore.js"; const OPEN_ON_PURPOSE = new AnyAccessGuard(); // Route-contract test: pin path + method + access-guard wiring so the Phase 1 @@ -15,26 +15,23 @@ const OPEN_ON_PURPOSE = new AnyAccessGuard(); const config: GetCollectionStatusConfig = { availableCollections: new Set(), failedWindowMs: 3 }; const denyAccess: AccessGuard = { check: vi.fn().mockReturnValue(false) }; +const noRuns = () => storeDouble(); + describe("createGetCollectionStatusRoute (contract)", () => { it("registers GET at {basePath}/collection/:collection_slug with the default basePath", () => { - const endpoint = createGetCollectionStatusRoute(config, { create: vi.fn() }, OPEN_ON_PURPOSE); + const endpoint = createGetCollectionStatusRoute(config, noRuns, OPEN_ON_PURPOSE); expect(endpoint.path).toBe("/translate/collection/:collection_slug"); expect(endpoint.method).toBe("get"); }); it("honors a custom basePath", () => { - const endpoint = createGetCollectionStatusRoute( - config, - { create: vi.fn() }, - OPEN_ON_PURPOSE, - "/i18n" - ); + const endpoint = createGetCollectionStatusRoute(config, noRuns, OPEN_ON_PURPOSE, "/i18n"); expect(endpoint.path).toBe("/i18n/collection/:collection_slug"); }); it("applies the access guard — a denied request gets 403 and never reaches the handler", async () => { - const taskRunnerFactory: TaskRunnerFactory = { create: vi.fn() }; - const endpoint = createGetCollectionStatusRoute(config, taskRunnerFactory, denyAccess); + const openTheRecord = vi.fn(() => storeDouble()); + const endpoint = createGetCollectionStatusRoute(config, openTheRecord, denyAccess); const response = await endpoint.handler({} as PayloadRequest); @@ -42,6 +39,6 @@ describe("createGetCollectionStatusRoute (contract)", () => { expect(denyAccess.check).toHaveBeenLastCalledWith( expect.objectContaining({ action: "collection-status" }) ); - expect(taskRunnerFactory.create).not.toHaveBeenCalled(); + expect(openTheRecord, "a refused request reads nothing").not.toHaveBeenCalled(); }); }); diff --git a/packages/payload-plugin-translator/src/server/features/get-collection-status/route.ts b/packages/payload-plugin-translator/src/server/features/get-collection-status/route.ts index dd8bc0554..f1f6cec91 100644 --- a/packages/payload-plugin-translator/src/server/features/get-collection-status/route.ts +++ b/packages/payload-plugin-translator/src/server/features/get-collection-status/route.ts @@ -2,7 +2,7 @@ import type { Endpoint } from "payload"; import { withErrorHandler, withAccessCheck } from "../../shared/index.js"; import type { AccessGuard } from "../../shared/index.js"; -import type { TaskRunnerFactory } from "../../modules/task-runner/index.js"; +import type { RunRecordStoreFactory } from "../../modules/runs/index.js"; import type { GetCollectionStatusConfig } from "./model.js"; import { GetCollectionStatusHandler } from "./handler.js"; @@ -12,11 +12,11 @@ import { GetCollectionStatusHandler } from "./handler.js"; */ export function createGetCollectionStatusRoute( config: GetCollectionStatusConfig, - taskRunnerFactory: TaskRunnerFactory, + runRecordStoreFactory: RunRecordStoreFactory, access: AccessGuard, basePath = "/translate" ): Endpoint { - const handler = new GetCollectionStatusHandler(config, taskRunnerFactory); + const handler = new GetCollectionStatusHandler(config, runRecordStoreFactory); return { path: `${basePath}/collection/:collection_slug`, diff --git a/packages/payload-plugin-translator/src/server/features/get-document-status/handler.test.ts b/packages/payload-plugin-translator/src/server/features/get-document-status/handler.test.ts index c7422b8e9..25d6efd23 100644 --- a/packages/payload-plugin-translator/src/server/features/get-document-status/handler.test.ts +++ b/packages/payload-plugin-translator/src/server/features/get-document-status/handler.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Payload, PayloadRequest, CollectionSlug } from "payload"; + import { GetDocumentStatusHandler } from "./handler.js"; import type { GetDocumentStatusConfig } from "./model.js"; -import type { TaskRunnerFactory, TaskRunner, Task } from "../../modules/task-runner/index.js"; -import { GENERIC_TRANSLATION_ERROR } from "../../shared/index.js"; +import { runRow, storeDouble } from "../../modules/runs/__tests__/fixtures/runRecordStore.js"; +import type { RunRecordStoreDouble } from "../../modules/runs/__tests__/fixtures/runRecordStore.js"; const everyDocumentIsVisible = () => vi.fn(async (args: { where?: { id?: { in?: string[] } } }) => ({ @@ -12,159 +13,140 @@ const everyDocumentIsVisible = () => describe("GetDocumentStatusHandler", () => { let handler: GetDocumentStatusHandler; - let mockTaskRunner: TaskRunner; - let mockTaskRunnerFactory: TaskRunnerFactory; + let runs: RunRecordStoreDouble; + let openTheRecord: ReturnType; let config: GetDocumentStatusConfig; - const createMockTask = (overrides: Partial = {}): Task => ({ - id: "task-123", - status: "completed", - input: { - collectionSlug: "posts" as CollectionSlug, - collectionId: "doc-123", - sourceLng: "en", - targetLng: "de", - publishOnTranslation: false, - }, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T01:00:00Z", - completedAt: "2024-01-01T01:00:00Z", - cancelled: false, - ...overrides, - }); - - const createMockRequest = (params: Record = {}): PayloadRequest => + const request = (params: Record = {}): PayloadRequest => ({ payload: { find: everyDocumentIsVisible() } as unknown as Payload, routeParams: params, }) as unknown as PayloadRequest; + const aboutDoc123 = () => request({ collection_slug: "posts", collection_id: "doc-123" }); + + const answered = async (req: PayloadRequest) => { + const response = await handler.handle(req); + return { status: response.status, body: await response.json() }; + }; + beforeEach(() => { - mockTaskRunner = { - enqueue: vi.fn(), - cancel: vi.fn(), - run: vi.fn(), - assignmentsOf: vi.fn().mockResolvedValue([]), - findByCollection: vi.fn().mockResolvedValue([]), - }; - - mockTaskRunnerFactory = { - create: vi.fn().mockReturnValue(mockTaskRunner), - }; - - config = { - availableCollections: new Set(["posts", "pages"] as CollectionSlug[]), - }; - - handler = new GetDocumentStatusHandler(config, mockTaskRunnerFactory); + vi.unstubAllEnvs(); + runs = storeDouble(); + openTheRecord = vi.fn(() => runs); + config = { availableCollections: new Set(["posts", "pages"] as CollectionSlug[]) }; + handler = new GetDocumentStatusHandler(config, openTheRecord); }); describe("validation", () => { - it("returns validation error for missing collection_slug", async () => { - const req = createMockRequest({ collection_id: "doc-123" }); - - const response = await handler.handle(req); - - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.message).toBe("Validation error"); + it("refuses a request with no collection", async () => { + expect((await answered(request({ collection_id: "doc-123" }))).status).toBe(400); }); - it("returns validation error for missing collection_id", async () => { - const req = createMockRequest({ collection_slug: "posts" }); + it("refuses a request with no document", async () => { + expect((await answered(request({ collection_slug: "posts" }))).status).toBe(400); + }); - const response = await handler.handle(req); + it("refuses a collection the plugin does not translate", async () => { + const { status, body } = await answered( + request({ collection_slug: "users", collection_id: "doc-123" }) + ); - expect(response.status).toBe(400); + expect(status).toBe(400); + expect(body.message).toBe("Collection not available for translation"); }); + }); - it("returns validation error for empty collection_slug", async () => { - const req = createMockRequest({ - collection_slug: "", - collection_id: "doc-123", + describe("what it reads", () => { + it("asks the run record about that one document, and the runner about nothing", async () => { + await answered(request({ collection_slug: "posts", collection_id: "doc-456" })); + + expect(runs.findByDocument).toHaveBeenCalledWith({ + collectionSlug: "posts", + collectionId: "doc-456", }); + }); - const response = await handler.handle(req); + it("opens the record on the request's own payload", async () => { + const req = aboutDoc123(); - expect(response.status).toBe(400); - }); - }); + await answered(req); - describe("collection availability", () => { - it("returns bad request for unavailable collection", async () => { - const req = createMockRequest({ - collection_slug: "users", - collection_id: "doc-123", - }); + expect(openTheRecord).toHaveBeenCalledWith(req.payload); + }); - const response = await handler.handle(req); + it("answers with nothing when the document has never been translated", async () => { + const { status, body } = await answered(aboutDoc123()); - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.message).toBe("Collection not available for translation"); + expect(status).toBe(200); + expect(body.data).toEqual([]); }); }); - describe("success responses", () => { - it("returns one task status entry per document, wrapped in an array", async () => { - const task = createMockTask(); - (mockTaskRunner.findByCollection as ReturnType).mockResolvedValue([task]); + describe("what it answers", () => { + it("names the run, the document and the locale", async () => { + runs.findByDocument.mockResolvedValue([ + runRow({ + handle: "job-7", + settledAt: "2026-10-27T10:05:00.000Z", + startedAt: "2026-10-27T10:01:00.000Z", + outcome: "delivered", + }), + ]); - const req = createMockRequest({ - collection_slug: "posts", - collection_id: "doc-123", - }); - const response = await handler.handle(req); + const { body } = await answered(aboutDoc123()); - expect(response.status).toBe(200); - const body = await response.json(); expect(body.data).toHaveLength(1); expect(body.data[0]).toMatchObject({ - id: "task-123", + id: "job-7", status: "completed", + completed_at: "2026-10-27T10:05:00.000Z", input: { - collection: { - relationTo: "posts", - value: "doc-123", - }, + collection: { relationTo: "posts", value: "doc-1" }, source_lng: "en", target_lng: "de", }, }); }); - it("returns one entry per target locale, keeping the latest job per locale", async () => { - // Two locales, and a superseded older job for `de` — the reduction keeps the newest per locale. - const deOld = createMockTask({ - id: "de-old", - input: { ...createMockTask().input, targetLng: "de" }, - createdAt: "2024-01-01T00:00:00Z", - status: "failed", - }); - const deNew = createMockTask({ - id: "de-new", - input: { ...createMockTask().input, targetLng: "de" }, - createdAt: "2024-01-02T00:00:00Z", - status: "running", - }); - const fr = createMockTask({ - id: "fr-1", - input: { ...createMockTask().input, targetLng: "fr" }, - createdAt: "2024-01-01T12:00:00Z", - status: "pending", - }); - (mockTaskRunner.findByCollection as ReturnType).mockResolvedValue([ - deOld, - deNew, - fr, + it("says pending for a locale nothing has started, and running once it has", async () => { + runs.findByDocument.mockResolvedValue([ + runRow({ targetLng: "fr" }), + runRow({ targetLng: "es", startedAt: "2026-10-27T10:01:00.000Z" }), ]); - const req = createMockRequest({ collection_slug: "posts", collection_id: "doc-123" }); - const body = await (await handler.handle(req)).json(); + const { body } = await answered(aboutDoc123()); + + const byLocale = Object.fromEntries( + (body.data as Array<{ status: string; input: { target_lng: string } }>).map((entry) => [ + entry.input.target_lng, + entry.status, + ]) + ); + expect(byLocale).toEqual({ fr: "pending", es: "running" }); + }); + + it("keeps one entry per target locale, the newest run for each", async () => { + runs.findByDocument.mockResolvedValue([ + runRow({ + handle: "de-old", + queuedAt: "2026-10-01T00:00:00.000Z", + outcome: "failed", + settledAt: "2026-10-01T00:01:00.000Z", + }), + runRow({ + handle: "de-new", + queuedAt: "2026-10-02T00:00:00.000Z", + startedAt: "2026-10-02T00:01:00.000Z", + }), + runRow({ handle: "fr-1", targetLng: "fr", queuedAt: "2026-10-01T12:00:00.000Z" }), + ]); + + const { body } = await answered(aboutDoc123()); const byLocale = Object.fromEntries( (body.data as Array<{ id: string; status: string; input: { target_lng: string } }>).map( - (job) => [job.input.target_lng, job] + (entry) => [entry.input.target_lng, entry] ) ); expect(Object.keys(byLocale).sort()).toEqual(["de", "fr"]); @@ -172,103 +154,67 @@ describe("GetDocumentStatusHandler", () => { expect(byLocale.fr).toMatchObject({ id: "fr-1", status: "pending" }); }); - it("returns an empty array when no task exists", async () => { - (mockTaskRunner.findByCollection as ReturnType).mockResolvedValue([]); - - const req = createMockRequest({ - collection_slug: "posts", - collection_id: "doc-123", - }); - const response = await handler.handle(req); - - expect(response.status).toBe(200); - const body = await response.json(); - expect(body.data).toEqual([]); - }); - - it("calls findByCollection with correct parameters", async () => { - const req = createMockRequest({ - collection_slug: "posts", - collection_id: "doc-456", - }); + it("says nothing about a locale whose only run was cancelled", async () => { + runs.findByDocument.mockResolvedValue([ + runRow({ targetLng: "de", outcome: "cancelled", settledAt: "2026-10-27T10:02:00.000Z" }), + runRow({ targetLng: "fr" }), + ]); - await handler.handle(req); + const { body } = await answered(aboutDoc123()); - expect(mockTaskRunner.findByCollection).toHaveBeenCalledWith("posts", { - documentIds: ["doc-456"], - }); + expect( + (body.data as Array<{ input: { target_lng: string } }>).map((e) => e.input.target_lng) + ).toEqual(["fr"]); }); - it("creates task runner with request payload", async () => { - const mockPayload = { - collections: {}, - find: everyDocumentIsVisible(), - } as unknown as Payload; - const req = createMockRequest({ - collection_slug: "posts", - collection_id: "doc-123", - }); - (req as any).payload = mockPayload; + it("falls back to what a locale last did when its newest run was cancelled", async () => { + runs.findByDocument.mockResolvedValue([ + runRow({ + handle: "delivered-first", + queuedAt: "2026-10-01T00:00:00.000Z", + startedAt: "2026-10-01T00:01:00.000Z", + settledAt: "2026-10-01T00:02:00.000Z", + outcome: "delivered", + }), + runRow({ + handle: "cancelled-later", + queuedAt: "2026-10-02T00:00:00.000Z", + settledAt: "2026-10-02T00:01:00.000Z", + outcome: "cancelled", + }), + ]); - await handler.handle(req); + const { body } = await answered(aboutDoc123()); - expect(mockTaskRunnerFactory.create).toHaveBeenCalledWith(mockPayload); + expect(body.data[0]).toMatchObject({ id: "delivered-first", status: "completed" }); }); - }); - describe("task status transformation", () => { - it("includes error information when present", async () => { - const task = createMockTask({ - status: "failed", - error: { message: "Translation failed" }, - }); - (mockTaskRunner.findByCollection as ReturnType).mockResolvedValue([task]); + it("hands the stored failure text on as it was stored, with no second cleaning", async () => { + vi.stubEnv("NODE_ENV", "production"); + runs.findByDocument.mockResolvedValue([ + runRow({ + outcome: "failed", + settledAt: "2026-10-27T10:05:00.000Z", + error: "The configured translation model is not available to this API key.", + }), + ]); - const req = createMockRequest({ - collection_slug: "posts", - collection_id: "doc-123", - }); - const response = await handler.handle(req); + const { body } = await answered(aboutDoc123()); - const body = await response.json(); expect(body.data[0].status).toBe("failed"); - expect(body.data[0].error).toEqual({ message: "Translation failed" }); - }); - - it("does not leak the raw error to the client in production", async () => { - vi.stubEnv("NODE_ENV", "production"); - try { - const task = createMockTask({ - status: "failed", - error: { message: "401 Incorrect API key provided: sk-proj-abc123" }, - }); - (mockTaskRunner.findByCollection as ReturnType).mockResolvedValue([task]); - - const req = createMockRequest({ - collection_slug: "posts", - collection_id: "doc-123", - }); - const body = await (await handler.handle(req)).json(); - - expect(body.data[0].error).toEqual({ message: GENERIC_TRANSLATION_ERROR }); - expect(JSON.stringify(body)).not.toContain("sk-proj"); - } finally { - vi.unstubAllEnvs(); - } + expect(body.data[0].error).toEqual({ + message: "The configured translation model is not available to this API key.", + }); }); - it("includes cancelled flag", async () => { - const task = createMockTask({ cancelled: true }); - (mockTaskRunner.findByCollection as ReturnType).mockResolvedValue([task]); + it("hides a document the caller may not read", async () => { + runs.findByDocument.mockResolvedValue([runRow()]); + const req = request({ collection_slug: "posts", collection_id: "doc-123" }); + (req.payload as unknown as { find: unknown }).find = vi.fn(async () => ({ docs: [] })); - const req = createMockRequest({ - collection_slug: "posts", - collection_id: "doc-123", - }); - const response = await handler.handle(req); + const { body } = await answered(req); - const body = await response.json(); - expect(body.data[0].cancelled).toBe(true); + expect(body.data).toEqual([]); }); }); }); diff --git a/packages/payload-plugin-translator/src/server/features/get-document-status/handler.ts b/packages/payload-plugin-translator/src/server/features/get-document-status/handler.ts index 9eb1c7686..c61568add 100644 --- a/packages/payload-plugin-translator/src/server/features/get-document-status/handler.ts +++ b/packages/payload-plugin-translator/src/server/features/get-document-status/handler.ts @@ -1,25 +1,24 @@ import type { PayloadRequest } from "payload"; import { ServerResponse } from "../../shared/index.js"; -import type { TaskRunnerFactory } from "../../modules/task-runner/index.js"; +import type { RunRecordStoreFactory } from "../../modules/runs/index.js"; import { isCollectionAvailable } from "../_lib/collection-utils.js"; import { visibleIds } from "../../shared/payload/collections.js"; -import { - GetDocumentStatusInputSchema, - latestTaskPerTargetLocale, - taskToJobStatusOutput, -} from "./model.js"; +import { reportedRuns } from "../../modules/runs/index.js"; + +import { GetDocumentStatusInputSchema, latestPerTargetLocale, runToJobStatus } from "./model.js"; import type { GetDocumentStatusConfig } from "./model.js"; -/** - * Gets the translation status for a specific document - */ +/** What each locale of a document is doing. */ export class GetDocumentStatusHandler { - constructor( - private readonly config: GetDocumentStatusConfig, - private readonly taskRunnerFactory: TaskRunnerFactory - ) {} + private readonly config: GetDocumentStatusConfig; + private readonly runRecordStoreFactory: RunRecordStoreFactory; + + constructor(config: GetDocumentStatusConfig, runRecordStoreFactory: RunRecordStoreFactory) { + this.config = config; + this.runRecordStoreFactory = runRecordStoreFactory; + } async handle(req: PayloadRequest): Promise { const validationResult = GetDocumentStatusInputSchema.safeParse(req.routeParams); @@ -37,9 +36,11 @@ export class GetDocumentStatusHandler { const visible = await visibleIds(req.payload, collectionSlug, [collection_id], req.user); if (!visible.has(collection_id)) return ServerResponse.success([]); - const runner = this.taskRunnerFactory.create(req.payload); - const tasks = await runner.findByCollection(collectionSlug, { documentIds: [collection_id] }); + const rows = await this.runRecordStoreFactory(req.payload).findByDocument({ + collectionSlug, + collectionId: collection_id, + }); - return ServerResponse.success(latestTaskPerTargetLocale(tasks).map(taskToJobStatusOutput)); + return ServerResponse.success(latestPerTargetLocale(reportedRuns(rows)).map(runToJobStatus)); } } diff --git a/packages/payload-plugin-translator/src/server/features/get-document-status/index.ts b/packages/payload-plugin-translator/src/server/features/get-document-status/index.ts index d86866538..f352e1e77 100644 --- a/packages/payload-plugin-translator/src/server/features/get-document-status/index.ts +++ b/packages/payload-plugin-translator/src/server/features/get-document-status/index.ts @@ -1,2 +1,2 @@ export { createGetDocumentStatusRoute } from "./route.js"; -export { taskToJobStatusOutput } from "./model.js"; +export { runToJobStatus } from "./model.js"; diff --git a/packages/payload-plugin-translator/src/server/features/get-document-status/model.test.ts b/packages/payload-plugin-translator/src/server/features/get-document-status/model.test.ts index c2c7c5c2f..787c18980 100644 --- a/packages/payload-plugin-translator/src/server/features/get-document-status/model.test.ts +++ b/packages/payload-plugin-translator/src/server/features/get-document-status/model.test.ts @@ -1,63 +1,47 @@ import { describe, it, expect } from "vitest"; -import type { CollectionSlug } from "payload"; - -import type { Task } from "../../modules/task-runner/index.js"; - -import { latestTaskPerTargetLocale } from "./model.js"; - -const task = (over: Partial & { targetLng: string; id: string }): Task => ({ - id: over.id, - status: over.status ?? "pending", - input: { - collectionSlug: "posts" as CollectionSlug, - collectionId: "doc-1", - sourceLng: "en", - targetLng: over.targetLng, - publishOnTranslation: false, - }, - createdAt: over.createdAt ?? "2024-01-01T00:00:00Z", - updatedAt: over.updatedAt ?? "2024-01-01T00:00:00Z", - cancelled: false, -}); -describe("latestTaskPerTargetLocale", () => { - it("returns one task per target locale", () => { - const result = latestTaskPerTargetLocale([ - task({ id: "de", targetLng: "de" }), - task({ id: "fr", targetLng: "fr" }), - ]); - expect(result.map((t) => t.input.targetLng).sort()).toEqual(["de", "fr"]); +import { reportedRuns } from "../../modules/runs/index.js"; +import { runRow } from "../../modules/runs/__tests__/fixtures/runRecordStore.js"; + +import { latestPerTargetLocale } from "./model.js"; + +const runs = (...rows: ReturnType[]) => reportedRuns(rows); + +describe("latestPerTargetLocale", () => { + it("returns one run per target locale", () => { + const kept = latestPerTargetLocale( + runs(runRow({ handle: "de", targetLng: "de" }), runRow({ handle: "fr", targetLng: "fr" })) + ); + + expect(kept.map(({ row }) => row.targetLng).sort()).toEqual(["de", "fr"]); }); - it("keeps the most recently created task for a locale", () => { - const result = latestTaskPerTargetLocale([ - task({ id: "old", targetLng: "de", createdAt: "2024-01-01T00:00:00Z" }), - task({ id: "new", targetLng: "de", createdAt: "2024-01-02T00:00:00Z" }), - ]); - expect(result).toHaveLength(1); - expect(result[0].id).toBe("new"); + it("keeps the run queued most recently for a locale", () => { + const kept = latestPerTargetLocale( + runs( + runRow({ handle: "old", queuedAt: "2026-10-01T00:00:00.000Z" }), + runRow({ handle: "new", queuedAt: "2026-10-02T00:00:00.000Z" }) + ) + ); + + expect(kept).toHaveLength(1); + expect(kept[0].row.handle).toBe("new"); }); - it("tie-breaks equal createdAt by the most recently updated task", () => { - const result = latestTaskPerTargetLocale([ - task({ - id: "stale", - targetLng: "de", - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - }), - task({ - id: "fresh", - targetLng: "de", - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T06:00:00Z", - }), - ]); - expect(result).toHaveLength(1); - expect(result[0].id).toBe("fresh"); + it("breaks a tie on when the locale was last touched", () => { + const sameMoment = "2026-10-01T00:00:00.000Z"; + const kept = latestPerTargetLocale( + runs( + runRow({ handle: "stale", queuedAt: sameMoment }), + runRow({ handle: "fresh", queuedAt: sameMoment, startedAt: "2026-10-01T06:00:00.000Z" }) + ) + ); + + expect(kept).toHaveLength(1); + expect(kept[0].row.handle).toBe("fresh"); }); - it("returns [] for no tasks", () => { - expect(latestTaskPerTargetLocale([])).toEqual([]); + it("answers with nothing when nothing has run", () => { + expect(latestPerTargetLocale([])).toEqual([]); }); }); diff --git a/packages/payload-plugin-translator/src/server/features/get-document-status/model.ts b/packages/payload-plugin-translator/src/server/features/get-document-status/model.ts index b1002f8d2..0c0eb98f7 100644 --- a/packages/payload-plugin-translator/src/server/features/get-document-status/model.ts +++ b/packages/payload-plugin-translator/src/server/features/get-document-status/model.ts @@ -1,8 +1,10 @@ import { z } from "zod"; import type { CollectionSlug } from "payload"; -import { JobIdSchema, toClientErrorMessage } from "../../shared/index.js"; -import type { Task, TaskStatus } from "../../modules/task-runner/index.js"; +import { JobIdSchema } from "../../shared/index.js"; +import type { TaskStatus } from "../../modules/task-runner/index.js"; +import { lastTouched } from "../../modules/runs/index.js"; +import type { ReportedRun } from "../../modules/runs/index.js"; /** * Input validation schema. @@ -56,48 +58,45 @@ export type GetDocumentStatusConfig = { availableCollections: Set; }; -const isNewerTask = (candidate: Task, current: Task): boolean => - candidate.createdAt !== current.createdAt - ? candidate.createdAt > current.createdAt - : candidate.updatedAt > current.updatedAt; +const isNewer = (candidate: ReportedRun, current: ReportedRun): boolean => + candidate.row.queuedAt !== current.row.queuedAt + ? candidate.row.queuedAt > current.row.queuedAt + : lastTouched(candidate.row) > lastTouched(current.row); /** - * Reduce a document's jobs to the latest one per target locale. + * One row per locale, out of the row-per-locale-per-run a document accumulates. * - * `findByCollection` returns every job for the document across all target locales (plus any superseded - * jobs not yet cancelled). The status panel shows one row per target locale, so we keep — per - * `targetLng` — the most recently created job (tie-break: most recently updated). Without this the - * caller sees a single arbitrary job and every other in-flight locale looks idle, which is the - * concurrent re-translate bug (a second re-translate appearing to overwrite the first's status). + * The input is already filtered to runs the panel has a word for, so a locale whose newest run was + * cancelled falls back to what it last actually did. */ -export function latestTaskPerTargetLocale(tasks: Task[]): Task[] { - const byLocale = new Map(); - for (const task of tasks) { - const current = byLocale.get(task.input.targetLng); - if (!current || isNewerTask(task, current)) byLocale.set(task.input.targetLng, task); +export function latestPerTargetLocale(runs: readonly ReportedRun[]): ReportedRun[] { + const byLocale = new Map(); + for (const run of runs) { + const current = byLocale.get(run.row.targetLng); + if (!current || isNewer(run, current)) byLocale.set(run.row.targetLng, run); } return [...byLocale.values()]; } /** - * Transforms a Task to API output format (snake_case for client compatibility) + * One run of one locale, in the shape the admin has always read. + * + * `error` is passed through rather than sanitised: the text is cleaned before it is stored, and a + * second pass would collapse a named reason's own copy to the generic line. */ -export function taskToJobStatusOutput(task: Task): JobStatusOutput { +export function runToJobStatus({ row, status }: ReportedRun): JobStatusOutput { return { - id: task.id, - status: task.status, - created_at: task.createdAt, - updated_at: task.updatedAt, - completed_at: task.completedAt, + id: row.handle, + status, + created_at: row.queuedAt, + updated_at: lastTouched(row), + completed_at: row.outcome === "delivered" ? (row.settledAt ?? undefined) : undefined, input: { - collection: { - relationTo: task.input.collectionSlug, - value: task.input.collectionId, - }, - source_lng: task.input.sourceLng, - target_lng: task.input.targetLng, + collection: { relationTo: row.collectionSlug, value: row.collectionId }, + source_lng: row.sourceLng, + target_lng: row.targetLng, }, - error: task.error ? { message: toClientErrorMessage(task.error.message) } : undefined, - cancelled: task.cancelled, + error: row.error === null ? undefined : { message: row.error }, + cancelled: false, }; } diff --git a/packages/payload-plugin-translator/src/server/features/get-document-status/route.test.ts b/packages/payload-plugin-translator/src/server/features/get-document-status/route.test.ts index 461bfaf58..68d4b3720 100644 --- a/packages/payload-plugin-translator/src/server/features/get-document-status/route.test.ts +++ b/packages/payload-plugin-translator/src/server/features/get-document-status/route.test.ts @@ -2,11 +2,11 @@ import { describe, it, expect, vi } from "vitest"; import type { PayloadRequest } from "payload"; import type { AccessGuard } from "../../shared/index.js"; -import type { TaskRunnerFactory } from "../../modules/task-runner/index.js"; import { createGetDocumentStatusRoute } from "./route.js"; import type { GetDocumentStatusConfig } from "./model.js"; import { AnyAccessGuard } from "../../shared/access/AnyAccessGuard.js"; +import { storeDouble } from "../../modules/runs/__tests__/fixtures/runRecordStore.js"; const OPEN_ON_PURPOSE = new AnyAccessGuard(); // Route-contract test: pin path + method + access-guard wiring so the Phase 1 @@ -15,26 +15,23 @@ const OPEN_ON_PURPOSE = new AnyAccessGuard(); const config: GetDocumentStatusConfig = { availableCollections: new Set() }; const denyAccess: AccessGuard = { check: vi.fn().mockReturnValue(false) }; +const noRuns = () => storeDouble(); + describe("createGetDocumentStatusRoute (contract)", () => { it("registers GET at {basePath}/document/:collection_slug/:collection_id with the default basePath", () => { - const endpoint = createGetDocumentStatusRoute(config, { create: vi.fn() }, OPEN_ON_PURPOSE); + const endpoint = createGetDocumentStatusRoute(config, noRuns, OPEN_ON_PURPOSE); expect(endpoint.path).toBe("/translate/document/:collection_slug/:collection_id"); expect(endpoint.method).toBe("get"); }); it("honors a custom basePath", () => { - const endpoint = createGetDocumentStatusRoute( - config, - { create: vi.fn() }, - OPEN_ON_PURPOSE, - "/i18n" - ); + const endpoint = createGetDocumentStatusRoute(config, noRuns, OPEN_ON_PURPOSE, "/i18n"); expect(endpoint.path).toBe("/i18n/document/:collection_slug/:collection_id"); }); it("applies the access guard — a denied request gets 403 and never reaches the handler", async () => { - const taskRunnerFactory: TaskRunnerFactory = { create: vi.fn() }; - const endpoint = createGetDocumentStatusRoute(config, taskRunnerFactory, denyAccess); + const openTheRecord = vi.fn(() => storeDouble()); + const endpoint = createGetDocumentStatusRoute(config, openTheRecord, denyAccess); const response = await endpoint.handler({} as PayloadRequest); @@ -42,6 +39,6 @@ describe("createGetDocumentStatusRoute (contract)", () => { expect(denyAccess.check).toHaveBeenLastCalledWith( expect.objectContaining({ action: "document-status" }) ); - expect(taskRunnerFactory.create).not.toHaveBeenCalled(); + expect(openTheRecord, "a refused request reads nothing").not.toHaveBeenCalled(); }); }); diff --git a/packages/payload-plugin-translator/src/server/features/get-document-status/route.ts b/packages/payload-plugin-translator/src/server/features/get-document-status/route.ts index 7560887da..07d709870 100644 --- a/packages/payload-plugin-translator/src/server/features/get-document-status/route.ts +++ b/packages/payload-plugin-translator/src/server/features/get-document-status/route.ts @@ -2,7 +2,7 @@ import type { Endpoint } from "payload"; import { withErrorHandler, withAccessCheck } from "../../shared/index.js"; import type { AccessGuard } from "../../shared/index.js"; -import type { TaskRunnerFactory } from "../../modules/task-runner/index.js"; +import type { RunRecordStoreFactory } from "../../modules/runs/index.js"; import type { GetDocumentStatusConfig } from "./model.js"; import { GetDocumentStatusHandler } from "./handler.js"; @@ -12,11 +12,11 @@ import { GetDocumentStatusHandler } from "./handler.js"; */ export function createGetDocumentStatusRoute( config: GetDocumentStatusConfig, - taskRunnerFactory: TaskRunnerFactory, + runRecordStoreFactory: RunRecordStoreFactory, access: AccessGuard, basePath = "/translate" ): Endpoint { - const handler = new GetDocumentStatusHandler(config, taskRunnerFactory); + const handler = new GetDocumentStatusHandler(config, runRecordStoreFactory); return { path: `${basePath}/document/:collection_slug/:collection_id`, diff --git a/packages/payload-plugin-translator/src/server/features/run-translation/__tests__/handler.access.test.ts b/packages/payload-plugin-translator/src/server/features/run-translation/__tests__/handler.access.test.ts index 8bd5030e8..d81adfc9b 100644 --- a/packages/payload-plugin-translator/src/server/features/run-translation/__tests__/handler.access.test.ts +++ b/packages/payload-plugin-translator/src/server/features/run-translation/__tests__/handler.access.test.ts @@ -1,7 +1,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Payload, PayloadRequest } from "payload"; -import type { JobAssignment, TaskRunner } from "../../../modules/task-runner/index.js"; +import type { CollectionSlug } from "payload"; + +import type { TaskRunner } from "../../../modules/task-runner/index.js"; +import type { RunRecord } from "../../../modules/runs/index.js"; +import { runRow, storeDouble } from "../../../modules/runs/__tests__/fixtures/runRecordStore.js"; vi.mock("../../../shared/access/refusedTargets.js", () => ({ refusedTargets: vi.fn().mockResolvedValue(new Set()), @@ -11,12 +15,13 @@ const { RunTranslationHandler } = await import("../handler.js"); const { refusedTargets } = await import("../../../shared/access/refusedTargets.js"); const ANNA = { id: "anna", collection: "users" }; -const MINE: JobAssignment = { +const MINE: RunRecord = runRow({ handle: "job-1", - collectionSlug: "posts" as never, + collectionSlug: "posts" as CollectionSlug, collectionId: "d1", - startedBy: { id: "anna", collection: "users" }, -}; + requesterId: "anna", + requesterCollection: "users", +}); describe("POST /run/:id asks about the document behind the job", () => { let runner: TaskRunner; @@ -29,15 +34,17 @@ describe("POST /run/:id asks about the document behind the job", () => { routeParams: { id: "job-1" }, }) as unknown as PayloadRequest; - const answering = (assignments: JobAssignment[]) => { + const answering = (rows: RunRecord[]) => { runner = { enqueue: vi.fn(), - cancel: vi.fn(), + stop: vi.fn(), run: vi.fn().mockResolvedValue({ success: true }), - findByCollection: vi.fn(), - assignmentsOf: vi.fn().mockResolvedValue(assignments), } as unknown as TaskRunner; - handler = new RunTranslationHandler({ create: vi.fn().mockReturnValue(runner) }); + const runs = storeDouble({ findByHandles: vi.fn().mockResolvedValue(rows) }); + handler = new RunTranslationHandler( + { create: vi.fn().mockReturnValue(runner), capabilities: { stop: true, run: true } }, + () => runs + ); }; beforeEach(() => { @@ -56,7 +63,7 @@ describe("POST /run/:id asks about the document behind the job", () => { }); it("refuses a job nobody is recorded as having started", async () => { - answering([{ ...MINE, startedBy: null }]); + answering([{ ...MINE, requesterId: null, requesterCollection: null }]); const response = await handler.handle(request()); diff --git a/packages/payload-plugin-translator/src/server/features/run-translation/handler.test.ts b/packages/payload-plugin-translator/src/server/features/run-translation/handler.test.ts index d56f66c51..1c78b19d3 100644 --- a/packages/payload-plugin-translator/src/server/features/run-translation/handler.test.ts +++ b/packages/payload-plugin-translator/src/server/features/run-translation/handler.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Payload, PayloadRequest } from "payload"; import type { TaskRunnerFactory, TaskRunner } from "../../modules/task-runner/index.js"; +import { runRow, storeDouble } from "../../modules/runs/__tests__/fixtures/runRecordStore.js"; +import type { RunRecordStoreDouble } from "../../modules/runs/__tests__/fixtures/runRecordStore.js"; vi.mock("../../shared/access/refusedTargets.js", () => ({ refusedTargets: vi.fn().mockResolvedValue(new Set()), @@ -13,6 +15,7 @@ describe("RunTranslationHandler", () => { let handler: InstanceType; let mockTaskRunner: TaskRunner; let mockTaskRunnerFactory: TaskRunnerFactory; + let runs: RunRecordStoreDouble; const createMockRequest = (params: Record = {}): PayloadRequest => ({ @@ -23,24 +26,24 @@ describe("RunTranslationHandler", () => { beforeEach(() => { mockTaskRunner = { enqueue: vi.fn(), - cancel: vi.fn(), + stop: vi.fn(), run: vi.fn().mockResolvedValue({ success: true }), - findByCollection: vi.fn(), - assignmentsOf: vi.fn().mockResolvedValue([ - { - handle: "task-456", - collectionSlug: "posts", - collectionId: "d1", - startedBy: { id: "anna", collection: "users" }, - }, - ]), }; mockTaskRunnerFactory = { create: vi.fn().mockReturnValue(mockTaskRunner), + capabilities: { stop: true, run: true }, }; - handler = new RunTranslationHandler(mockTaskRunnerFactory); + runs = storeDouble({ + findByHandles: vi + .fn() + .mockResolvedValue([ + runRow({ handle: "task-456", requesterId: "anna", requesterCollection: "users" }), + ]), + }); + + handler = new RunTranslationHandler(mockTaskRunnerFactory, () => runs); }); describe("validation", () => { @@ -138,4 +141,17 @@ describe("RunTranslationHandler", () => { expect(body.message).toBe("Translation task is already in progress"); }); }); + describe("a runner that cannot run a job on demand", () => { + it("is answered rather than called, although this route is not registered for one", async () => { + const cannotRun = { enqueue: vi.fn() } as unknown as TaskRunner; + const handler = new RunTranslationHandler( + { create: vi.fn().mockReturnValue(cannotRun), capabilities: { stop: false, run: false } }, + () => runs + ); + + const response = await handler.handle(createMockRequest({ id: "task-456" })); + + expect(response.status).toBe(501); + }); + }); }); diff --git a/packages/payload-plugin-translator/src/server/features/run-translation/handler.ts b/packages/payload-plugin-translator/src/server/features/run-translation/handler.ts index 4f4078fc6..0184c2244 100644 --- a/packages/payload-plugin-translator/src/server/features/run-translation/handler.ts +++ b/packages/payload-plugin-translator/src/server/features/run-translation/handler.ts @@ -2,15 +2,21 @@ import type { PayloadRequest } from "payload"; import { ServerResponse } from "../../shared/index.js"; import { authorisedHandles } from "../../shared/access/authorised.js"; +import { assignmentsFromRows } from "../../modules/runs/index.js"; +import type { RunRecordStoreFactory } from "../../modules/runs/index.js"; import type { TaskRunnerFactory } from "../../modules/task-runner/index.js"; import { RunInputSchema } from "./model.js"; -/** - * Runs a translation task by ID - */ +/** Runs a queued translation now, for hosts whose queue moves only when asked. */ export class RunTranslationHandler { - constructor(private readonly taskRunnerFactory: TaskRunnerFactory) {} + private readonly taskRunnerFactory: TaskRunnerFactory; + private readonly runRecordStoreFactory: RunRecordStoreFactory; + + constructor(taskRunnerFactory: TaskRunnerFactory, runRecordStoreFactory: RunRecordStoreFactory) { + this.taskRunnerFactory = taskRunnerFactory; + this.runRecordStoreFactory = runRecordStoreFactory; + } async handle(req: PayloadRequest): Promise { const validationResult = RunInputSchema.safeParse(req.routeParams); @@ -18,13 +24,9 @@ export class RunTranslationHandler { return ServerResponse.validationError(validationResult.error.issues); const { id } = validationResult.data; - const runner = this.taskRunnerFactory.create(req.payload); - - const [job] = await runner.assignmentsOf([id]); + const rows = await this.runRecordStoreFactory(req.payload).findByHandles([id]); + const [job] = assignmentsFromRows(rows); if (!job) return ServerResponse.notFound("Queued task not found"); - - // `enforcedAtTheWrite` turns a null requester into an unchecked write, so the ask below is not - // enough on its own. if (!job.startedBy) return ServerResponse.forbidden(); const { authorised } = await authorisedHandles({ @@ -35,8 +37,12 @@ export class RunTranslationHandler { const [thisJob] = authorised; if (!thisJob) return ServerResponse.forbidden(); - const result = await runner.run(thisJob); + const runner = this.taskRunnerFactory.create(req.payload); + if (!runner.run) { + return ServerResponse.notImplemented("This runner cannot run a job on demand."); + } + const result = await runner.run(thisJob); if (!result.success) { if (result.error === "not_found" || result.error === "already_completed") { return ServerResponse.notFound("Queued task not found"); diff --git a/packages/payload-plugin-translator/src/server/features/run-translation/route.test.ts b/packages/payload-plugin-translator/src/server/features/run-translation/route.test.ts index 1ceb0fef3..8c778cf6e 100644 --- a/packages/payload-plugin-translator/src/server/features/run-translation/route.test.ts +++ b/packages/payload-plugin-translator/src/server/features/run-translation/route.test.ts @@ -6,6 +6,7 @@ import type { TaskRunnerFactory } from "../../modules/task-runner/index.js"; import { createRunRoute } from "./route.js"; import { AnyAccessGuard } from "../../shared/access/AnyAccessGuard.js"; +import { storeDouble } from "../../modules/runs/__tests__/fixtures/runRecordStore.js"; const OPEN_ON_PURPOSE = new AnyAccessGuard(); // Route-contract test: pin path + method + access-guard wiring so the Phase 1 @@ -13,21 +14,35 @@ const OPEN_ON_PURPOSE = new AnyAccessGuard(); // and the shared wrappers have their own unit tests. const denyAccess: AccessGuard = { check: vi.fn().mockReturnValue(false) }; +const noRuns = () => storeDouble(); + describe("createRunRoute (contract)", () => { it("registers POST at {basePath}/run/:id with the default basePath", () => { - const endpoint = createRunRoute({ create: vi.fn() }, OPEN_ON_PURPOSE); + const endpoint = createRunRoute( + { create: vi.fn(), capabilities: { stop: true, run: true } }, + noRuns, + OPEN_ON_PURPOSE + ); expect(endpoint.path).toBe("/translate/run/:id"); expect(endpoint.method).toBe("post"); }); it("honors a custom basePath", () => { - const endpoint = createRunRoute({ create: vi.fn() }, OPEN_ON_PURPOSE, "/i18n"); + const endpoint = createRunRoute( + { create: vi.fn(), capabilities: { stop: true, run: true } }, + noRuns, + OPEN_ON_PURPOSE, + "/i18n" + ); expect(endpoint.path).toBe("/i18n/run/:id"); }); it("applies the access guard — a denied request gets 403 and never reaches the handler", async () => { - const taskRunnerFactory: TaskRunnerFactory = { create: vi.fn() }; - const endpoint = createRunRoute(taskRunnerFactory, denyAccess); + const taskRunnerFactory: TaskRunnerFactory = { + create: vi.fn(), + capabilities: { stop: true, run: true }, + }; + const endpoint = createRunRoute(taskRunnerFactory, noRuns, denyAccess); const response = await endpoint.handler({} as PayloadRequest); diff --git a/packages/payload-plugin-translator/src/server/features/run-translation/route.ts b/packages/payload-plugin-translator/src/server/features/run-translation/route.ts index 7a4b4b51e..1e0d8e271 100644 --- a/packages/payload-plugin-translator/src/server/features/run-translation/route.ts +++ b/packages/payload-plugin-translator/src/server/features/run-translation/route.ts @@ -3,6 +3,7 @@ import type { Endpoint } from "payload"; import { withErrorHandler, withAccessCheck } from "../../shared/index.js"; import type { AccessGuard } from "../../shared/index.js"; import type { TaskRunnerFactory } from "../../modules/task-runner/index.js"; +import type { RunRecordStoreFactory } from "../../modules/runs/index.js"; import { RunTranslationHandler } from "./handler.js"; @@ -11,10 +12,11 @@ import { RunTranslationHandler } from "./handler.js"; */ export function createRunRoute( taskRunnerFactory: TaskRunnerFactory, + runRecordStoreFactory: RunRecordStoreFactory, access: AccessGuard, basePath = "/translate" ): Endpoint { - const handler = new RunTranslationHandler(taskRunnerFactory); + const handler = new RunTranslationHandler(taskRunnerFactory, runRecordStoreFactory); return { path: `${basePath}/run/:id`, diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.access.test.ts b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.access.test.ts index e380ecdea..d06e90e64 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.access.test.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.access.test.ts @@ -5,7 +5,7 @@ import { translateDocument } from "../translateDocument.js"; import type { TranslateDocumentDeps } from "../translateDocument.js"; import type { TranslationProvider } from "../../../../core/domain/translation-providers/index.js"; import type { CollectionSchemaMap } from "../../../../types/CollectionSchemaMap.js"; -import type { TaskHandlerInput } from "../../../modules/task-runner/index.js"; +import type { TranslateDocumentInput } from "../translateDocument.js"; import { asRequester } from "../../../shared/payload/RequestScope.shapes.js"; vi.mock("../../../../core/translation-pipeline/index.js", () => ({ @@ -40,7 +40,7 @@ describe("translateDocument — it asks before it writes", () => { let payload: Payload; let provider: TranslationProvider; - const input = (over: Partial = {}): TaskHandlerInput => ({ + const input = (over: Partial = {}): TranslateDocumentInput => ({ collection: "posts" as CollectionSlug, collectionId: "doc-123", sourceLng: "en", diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.criteria.test.ts b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.criteria.test.ts index 8427a32ce..8be40cf99 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.criteria.test.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.criteria.test.ts @@ -7,7 +7,7 @@ import type { CollectionSchemaMap } from "../../../../types/CollectionSchemaMap. import type { Criteria } from "../../../modules/translation-run/index.js"; import type { TranslationFilter } from "../../../../core/domain/selection/index.js"; import { translateDocument } from "../translateDocument.js"; -import type { TaskHandlerInput } from "../../../modules/task-runner/index.js"; +import type { TranslateDocumentInput } from "../translateDocument.js"; const leaves = (...addresses: string[]): TranslationFilter => ({ kind: "leaves", addresses }); const rule = (name: string): TranslationFilter => ({ kind: "rule", name }); @@ -29,7 +29,7 @@ describe("what a run translates", () => { let schemaMap: CollectionSchemaMap; let payload: Payload; - const input = (overrides: Partial = {}): TaskHandlerInput => ({ + const input = (overrides: Partial = {}): TranslateDocumentInput => ({ collection: "posts" as CollectionSlug, collectionId: "doc-1", sourceLng: "en", @@ -45,7 +45,7 @@ describe("what a run translates", () => { return (call?.[0] as { selection?: readonly string[] })?.selection; }; - const run = async (over: Partial, criteria: Criteria = {}) => + const run = async (over: Partial, criteria: Criteria = {}) => await translateDocument( { translationProvider: provider, diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.enforce.test.ts b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.enforce.test.ts index 32771587d..0e473393e 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.enforce.test.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.enforce.test.ts @@ -5,7 +5,7 @@ import { translateDocument } from "../translateDocument.js"; import type { TranslateDocumentDeps } from "../translateDocument.js"; import type { TranslationProvider } from "../../../../core/domain/translation-providers/index.js"; import type { CollectionSchemaMap } from "../../../../types/CollectionSchemaMap.js"; -import type { TaskHandlerInput } from "../../../modules/task-runner/index.js"; +import type { TranslateDocumentInput } from "../translateDocument.js"; import { asRequester } from "../../../shared/payload/RequestScope.shapes.js"; vi.mock("../../../../core/translation-pipeline/index.js", () => ({ @@ -41,7 +41,7 @@ describe("translateDocument — where Payload's own enforcement is affordable", let deps: TranslateDocumentDeps; let payload: Payload; - const input = (): TaskHandlerInput => ({ + const input = (): TranslateDocumentInput => ({ collection: "posts" as CollectionSlug, collectionId: "doc-123", sourceLng: "en", diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.moduleFailure.test.ts b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.moduleFailure.test.ts index a63247c3b..1510ddd56 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.moduleFailure.test.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.moduleFailure.test.ts @@ -7,7 +7,7 @@ import type { CollectionSchemaMap } from "../../../../types/CollectionSchemaMap. import type { ChainMember } from "../../../modules/translation-run/index.js"; import { translateDocument } from "../translateDocument.js"; import type { TranslateDocumentDeps } from "../translateDocument.js"; -import type { TaskHandlerInput } from "../../../modules/task-runner/index.js"; +import type { TranslateDocumentInput } from "../translateDocument.js"; vi.mock("../../../../core/translation-pipeline/index.js", () => ({ translateContent: vi.fn().mockResolvedValue({ @@ -18,7 +18,7 @@ vi.mock("../../../../core/translation-pipeline/index.js", () => ({ const INSIDE_THE_EDITORS_SAVE = { transactionID: "tx-42" }; -const input = (): TaskHandlerInput => ({ +const input = (): TranslateDocumentInput => ({ collection: "posts" as CollectionSlug, collectionId: "doc-123", sourceLng: "en", diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.spend.test.ts b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.spend.test.ts index 0058bdb55..251a0e188 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.spend.test.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.spend.test.ts @@ -5,7 +5,7 @@ import { translateDocument } from "../translateDocument.js"; import type { TranslateDocumentDeps } from "../translateDocument.js"; import type { TranslationProvider } from "../../../../core/domain/translation-providers/index.js"; import type { CollectionSchemaMap } from "../../../../types/CollectionSchemaMap.js"; -import type { TaskHandlerInput } from "../../../modules/task-runner/index.js"; +import type { TranslateDocumentInput } from "../translateDocument.js"; vi.mock("../../../../core/translation-pipeline/index.js", () => ({ translateContent: vi.fn().mockResolvedValue({ @@ -34,7 +34,7 @@ describe("translateDocument — a refusal costs nothing at the provider", () => let deps: TranslateDocumentDeps; let payload: Payload; - const input = (): TaskHandlerInput => ({ + const input = (): TranslateDocumentInput => ({ collection: "posts" as CollectionSlug, collectionId: "doc-123", sourceLng: "en", diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.test.ts b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.test.ts index e801e9f46..82d231639 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.test.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.test.ts @@ -14,7 +14,7 @@ import type { CollectionSchemaMap } from "../../../../types/CollectionSchemaMap. import { AUTO_TRANSLATE_SKIP_CONTEXT_KEY } from "../../../../types/AutoTranslateContext.js"; import { ProvenanceService, provenanceIo } from "../../../modules/provenance/index.js"; import type { ProvenanceServiceFactory } from "../../../modules/provenance/index.js"; -import type { TaskHandlerInput } from "../../../modules/task-runner/index.js"; +import type { TranslateDocumentInput } from "../translateDocument.js"; import { provenanceInAChain } from "../../../modules/provenance/__tests__/fixtures/inAChain.js"; import { storeDouble } from "../../../../core/domain/provenance/__tests__/fixtures/provenanceStore.js"; import type { ProvenanceStore } from "../../../../core/domain/provenance/index.js"; @@ -40,7 +40,9 @@ describe("translateDocument", () => { const TARGET_LNG = "de"; - const createInput = (overrides: Partial = {}): TaskHandlerInput => ({ + const createInput = ( + overrides: Partial = {} + ): TranslateDocumentInput => ({ collection: "posts" as CollectionSlug, collectionId: "doc-123", sourceLng: "en", diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.transaction.test.ts b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.transaction.test.ts index 94b597f04..02384a885 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.transaction.test.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/handler.transaction.test.ts @@ -5,7 +5,7 @@ import { translateDocument } from "../translateDocument.js"; import type { TranslateDocumentDeps } from "../translateDocument.js"; import type { TranslationProvider } from "../../../../core/domain/translation-providers/index.js"; import type { CollectionSchemaMap } from "../../../../types/CollectionSchemaMap.js"; -import type { TaskHandlerInput } from "../../../modules/task-runner/index.js"; +import type { TranslateDocumentInput } from "../translateDocument.js"; import type { ProvenanceServiceFactory } from "../../../modules/provenance/index.js"; import { provenanceInAChain } from "../../../modules/provenance/__tests__/fixtures/inAChain.js"; @@ -22,7 +22,7 @@ describe("translateDocument — the caller's transaction", () => { let deps: TranslateDocumentDeps; let payload: Payload; - const input = (over: Partial = {}): TaskHandlerInput => ({ + const input = (over: Partial = {}): TranslateDocumentInput => ({ collection: "posts" as CollectionSlug, collectionId: "doc-123", sourceLng: "en", diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/readsTheRun.test.ts b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/readsTheRun.test.ts new file mode 100644 index 000000000..117bfb35d --- /dev/null +++ b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/readsTheRun.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Payload } from "payload"; + +import { wireTranslateRunner } from "../wireTranslateRunner.js"; +import { runRow, storeDouble } from "../../../modules/runs/__tests__/fixtures/runRecordStore.js"; +import type { RunRecordStoreDouble } from "../../../modules/runs/__tests__/fixtures/runRecordStore.js"; +import { RunRecordMissing } from "../../../modules/runs/index.js"; +import type { TaskRunnerContext, TaskRunnerProvider } from "../../../modules/task-runner/index.js"; +import type { CollectionSchemaMap } from "../../../../types/CollectionSchemaMap.js"; +import type { TranslationProvider } from "../../../../core/domain/translation-providers/index.js"; + +const { translateDocument } = vi.hoisted(() => ({ + translateDocument: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("../translateDocument.js", () => ({ translateDocument })); + +const pointer = { handle: "job-7", targetLng: "de", previousAttempts: 0 }; + +const idleRunner = (keep: (context: TaskRunnerContext) => void): TaskRunnerProvider => ({ + capabilities: { stop: false, run: false }, + create: (_payload, context) => { + keep(context); + return { enqueue: async () => [] }; + }, + configure: (context) => { + keep(context); + return (config) => config; + }, +}); + +describe("a runner names a run and a locale; the record says what that is", () => { + let store: RunRecordStoreDouble; + let context: TaskRunnerContext; + let payload: Payload; + + const whatWasTranslated = () => translateDocument.mock.calls[0][2]; + const underWhoseRights = () => translateDocument.mock.calls[0][3]; + + beforeEach(() => { + vi.clearAllMocks(); + store = storeDouble({ find: vi.fn().mockResolvedValue(runRow({ handle: "job-7" })) }); + payload = { logger: { error: vi.fn() } } as unknown as Payload; + + let captured: TaskRunnerContext | undefined; + wireTranslateRunner({ + translation: { + translationProvider: {} as TranslationProvider, + schemaMap: new Map() as CollectionSchemaMap, + modules: [], + }, + runner: idleRunner((handed) => { + captured = handed; + }), + lifecycle: {}, + storeFactory: () => store, + }); + if (!captured) throw new Error("the runner was never handed a context"); + context = captured; + }); + + it("asks the record about the locale the runner named", async () => { + await context.handler(payload, pointer, {}); + + expect(store.find).toHaveBeenCalledWith({ handle: "job-7", targetLng: "de" }); + }); + + it("translates the document the record names, not one the runner carried", async () => { + store.find.mockResolvedValue( + runRow({ handle: "job-7", collectionId: "doc-9", sourceLng: "fr", targetLng: "de" }) + ); + + await context.handler(payload, pointer, {}); + + expect(whatWasTranslated()).toMatchObject({ + collection: "posts", + collectionId: "doc-9", + sourceLng: "fr", + targetLng: "de", + }); + }); + + it("reads back the filters the run was queued with", async () => { + store.find.mockResolvedValue( + runRow({ handle: "job-7", filters: JSON.stringify([{ kind: "rule", name: "stale" }]) }) + ); + + await context.handler(payload, pointer, {}); + + expect(whatWasTranslated().filters).toEqual([{ kind: "rule", name: "stale" }]); + }); + + it("drops a filter entry the parser rejects rather than translating the whole document", async () => { + store.find.mockResolvedValue( + runRow({ handle: "job-7", filters: JSON.stringify([{ nonsense: true }]) }) + ); + + await context.handler(payload, pointer, {}); + + expect( + whatWasTranslated().filters, + "reading an unreadable entry as no filter would overwrite reviewed text" + ).toEqual([]); + }); + + it("carries the publish flag the run was queued with", async () => { + store.find.mockResolvedValue(runRow({ handle: "job-7", publishOnTranslation: true })); + + await context.handler(payload, pointer, {}); + + expect(whatWasTranslated().publishOnTranslation).toBe(true); + }); + + it("writes under the rights of whoever asked, which the record remembers", async () => { + store.find.mockResolvedValue( + runRow({ handle: "job-7", requesterId: "anna", requesterCollection: "users" }) + ); + + await context.handler(payload, pointer, {}); + + expect(underWhoseRights()).toMatchObject({ + requester: { userId: "anna", userCollection: "users" }, + }); + }); + + it("writes under nobody's rights when the run recorded nobody", async () => { + await context.handler(payload, pointer, {}); + + expect(underWhoseRights().requester).toBeNull(); + }); + + it("passes the attempt the runner is on", async () => { + await context.handler(payload, { ...pointer, previousAttempts: 2 }, {}); + + expect(whatWasTranslated().previousAttempts).toBe(2); + }); + + it("stamps the start before the translation runs", async () => { + await context.handler(payload, pointer, {}); + + expect(store.recordStarted).toHaveBeenCalledWith( + { handle: "job-7", targetLng: "de" }, + expect.objectContaining({ attempts: 1 }) + ); + expect(store.recordStarted.mock.invocationCallOrder[0]).toBeLessThan( + translateDocument.mock.invocationCallOrder[0] + ); + }); + + it("refuses to translate a locale the record does not name, rather than guessing one", async () => { + store.find.mockResolvedValue(null); + + await expect(context.handler(payload, pointer, {})).rejects.toBeInstanceOf(RunRecordMissing); + expect(translateDocument).not.toHaveBeenCalled(); + }); + + it("says in that refusal why the row should have been there", async () => { + store.find.mockResolvedValue(null); + + await expect(context.handler(payload, pointer, {})).rejects.toThrow(/the queue still holds/u); + }); +}); diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/recordTheRun.test.ts b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/recordTheRun.test.ts new file mode 100644 index 000000000..e08aa6275 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/features/translate-document/__tests__/recordTheRun.test.ts @@ -0,0 +1,224 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { CollectionSlug, Payload } from "payload"; + +import { wireTranslateRunner } from "../wireTranslateRunner.js"; +import { runRow, storeDouble } from "../../../modules/runs/__tests__/fixtures/runRecordStore.js"; +import type { RunRecordStoreDouble } from "../../../modules/runs/__tests__/fixtures/runRecordStore.js"; +import type { TaskRunnerContext, TaskRunnerProvider } from "../../../modules/task-runner/index.js"; +import type { QueuedWork } from "../../../modules/task-runner/types.js"; +import type { RequestScope } from "../../../shared/payload/RequestScope.shapes.js"; +import { asRequester } from "../../../shared/payload/RequestScope.shapes.js"; +import type { CollectionSchemaMap } from "../../../../types/CollectionSchemaMap.js"; +import type { TranslationProvider } from "../../../../core/domain/translation-providers/index.js"; + +const { translateDocument } = vi.hoisted(() => ({ + translateDocument: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("../translateDocument.js", () => ({ translateDocument })); + +const theRun = { handle: "job-7", targetLng: "de" }; + +const work = (over: Partial = {}): QueuedWork => ({ + collectionSlug: "posts" as CollectionSlug, + collectionId: "doc-1", + sourceLng: "en", + publishOnTranslation: false, + ...over, +}); + +const idleRunner = (keep: (context: TaskRunnerContext) => void): TaskRunnerProvider => ({ + capabilities: { stop: false, run: false }, + create: (_payload, context) => { + keep(context); + return { enqueue: async () => [] }; + }, + configure: (context) => { + keep(context); + return (config) => config; + }, +}); + +describe("the plugin's own record of a run", () => { + let store: RunRecordStoreDouble; + let openedWith: RequestScope[]; + let context: TaskRunnerContext; + let payload: Payload; + let logged: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + store = storeDouble({ find: vi.fn().mockResolvedValue(runRow({ handle: "job-7" })) }); + openedWith = []; + logged = vi.fn(); + payload = { logger: { error: logged } } as unknown as Payload; + + let captured: TaskRunnerContext | undefined; + wireTranslateRunner({ + translation: { + translationProvider: {} as TranslationProvider, + schemaMap: new Map() as CollectionSchemaMap, + modules: [], + }, + runner: idleRunner((handed) => { + captured = handed; + }), + lifecycle: {}, + storeFactory: (_payload, scope) => { + openedWith.push(scope ?? {}); + return store; + }, + }); + if (!captured) throw new Error("the runner was never handed a context"); + context = captured; + }); + + describe("when a locale is queued", () => { + it("writes one row carrying the work and whoever asked for it", async () => { + await context.report( + payload, + theRun, + { state: "queued", work: work({ publishOnTranslation: true }) }, + { requester: asRequester(7, "users") } + ); + + expect(store.recordQueued).toHaveBeenCalledTimes(1); + expect(store.recordQueued).toHaveBeenCalledWith( + expect.objectContaining({ + handle: "job-7", + targetLng: "de", + collectionSlug: "posts", + collectionId: "doc-1", + sourceLng: "en", + publishOnTranslation: true, + requesterId: "7", + requesterCollection: "users", + }) + ); + }); + + it("stamps the moment it was queued", async () => { + await context.report(payload, theRun, { state: "queued", work: work() }); + + const [written] = store.recordQueued.mock.calls[0]; + expect(Number.isNaN(Date.parse(written.queuedAt))).toBe(false); + }); + + it("records nobody when the request carried no requester", async () => { + await context.report(payload, theRun, { state: "queued", work: work() }); + + expect(store.recordQueued).toHaveBeenCalledWith( + expect.objectContaining({ requesterId: null, requesterCollection: null }) + ); + }); + + it("fails the report when the row cannot be written, rather than losing the run", async () => { + const boom = new Error("the database said no"); + store.recordQueued.mockRejectedValue(boom); + + await expect(context.report(payload, theRun, { state: "queued", work: work() })).rejects.toBe( + boom + ); + }); + + it("opens the store on the caller's transaction", async () => { + await context.report( + payload, + theRun, + { state: "queued", work: work() }, + { + transactionID: "tx-9", + } + ); + + expect(openedWith).toContainEqual(expect.objectContaining({ transactionID: "tx-9" })); + }); + }); + + describe("when a locale settles", () => { + it("records a delivery with no failure text", async () => { + await context.report(payload, theRun, { state: "delivered" }); + + expect(store.recordSettled).toHaveBeenCalledWith( + theRun, + expect.objectContaining({ outcome: "delivered", error: null }) + ); + }); + + it("records a cancellation with no failure text", async () => { + await context.report(payload, theRun, { state: "cancelled" }); + + expect(store.recordSettled).toHaveBeenCalledWith( + theRun, + expect.objectContaining({ outcome: "cancelled", error: null }) + ); + }); + + it("records a failure with text a reader can act on", async () => { + vi.stubEnv("NODE_ENV", "development"); + + await context.report(payload, theRun, { + state: "failed", + error: new Error("the model refused"), + }); + + expect(store.recordSettled).toHaveBeenCalledWith( + theRun, + expect.objectContaining({ outcome: "failed", error: "the model refused" }) + ); + }); + + it("keeps provider detail out of the row", async () => { + vi.stubEnv("NODE_ENV", "production"); + + await context.report(payload, theRun, { + state: "failed", + error: new Error("401 Incorrect API key provided: sk-proj-9f3a"), + }); + + const [, settlement] = store.recordSettled.mock.calls[0]; + expect(settlement.error).not.toContain("sk-proj"); + expect(settlement.error).not.toContain("401"); + }); + + it("stamps when the plugin was told, not when it happened", async () => { + await context.report(payload, theRun, { state: "delivered" }); + + const [, settlement] = store.recordSettled.mock.calls[0]; + expect(Number.isNaN(Date.parse(settlement.settledAt))).toBe(false); + }); + + it("finds the document in the record, because the runner no longer knows it", async () => { + await context.report(payload, theRun, { state: "delivered" }); + + expect(store.find).toHaveBeenCalledWith(theRun); + }); + + it("settles a locale whose row has been pruned, and tells the host nothing it cannot name", async () => { + store.find.mockResolvedValue(null); + + await expect( + context.report(payload, theRun, { state: "delivered" }) + ).resolves.toBeUndefined(); + expect(store.recordSettled).toHaveBeenCalled(); + }); + + it("logs and carries on when the settlement cannot be written outside a transaction", async () => { + store.recordSettled.mockRejectedValue(new Error("the database said no")); + + await expect( + context.report(payload, theRun, { state: "delivered" }) + ).resolves.toBeUndefined(); + expect(logged).toHaveBeenCalled(); + }); + + it("lets the failure out inside the caller's transaction, which it has already killed", async () => { + const boom = new Error("the database said no"); + store.recordSettled.mockRejectedValue(boom); + + await expect( + context.report(payload, theRun, { state: "delivered" }, { transactionID: "tx-9" }) + ).rejects.toBe(boom); + }); + }); +}); diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/run.ts b/packages/payload-plugin-translator/src/server/features/translate-document/run.ts index 2801f24fd..4a73d0540 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-document/run.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-document/run.ts @@ -4,7 +4,7 @@ import { TranslatorConfigError } from "../../../core/errors/index.js"; import type { FieldLike } from "../../../core/kernel/field-traversal/index.js"; import type { CollectionSchemaMap } from "../../../types/CollectionSchemaMap.js"; import type { RunTarget } from "../../modules/translation-run/index.js"; -import type { TaskHandlerInput } from "../../modules/task-runner/index.js"; +import type { TranslateDocumentInput } from "./translateDocument.js"; import type { RequestScope } from "../../shared/payload/RequestScope.shapes.js"; import { openTargetDocument } from "./targetDocument.js"; import type { TargetDocument } from "./targetDocument.js"; @@ -22,7 +22,10 @@ export type Run = { export async function openRun(args: { payload: Payload; - taskInput: Pick; + taskInput: Pick< + TranslateDocumentInput, + "collection" | "collectionId" | "sourceLng" | "targetLng" + >; scope: RequestScope; schemaMap: CollectionSchemaMap; }): Promise { diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/translateDocument.ts b/packages/payload-plugin-translator/src/server/features/translate-document/translateDocument.ts index db7258c92..cc4f038a8 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-document/translateDocument.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-document/translateDocument.ts @@ -1,8 +1,9 @@ -import type { Payload } from "payload"; +import type { CollectionSlug, Payload } from "payload"; import type { TranslationProvider } from "../../../core/domain/translation-providers/index.js"; import type { CollectionSchemaMap } from "../../../types/CollectionSchemaMap.js"; -import type { TaskHandlerInput } from "../../modules/task-runner/index.js"; +import type { ID } from "../../modules/task-runner/index.js"; +import type { TranslationFilter } from "../../../core/domain/selection/index.js"; import type { ChainMember, Criteria } from "../../modules/translation-run/index.js"; import type { RequestScope } from "../../shared/payload/RequestScope.shapes.js"; import { reportNothingSent } from "./reportNothingSent.js"; @@ -12,6 +13,20 @@ import { runModulesAfter, runModulesBefore } from "./runModules.js"; import { selectLeaves } from "./selectLeaves.js"; import { translateOrWrap } from "./translateOrWrap.js"; +/** + * What translating one locale needs to know. Assembled by the caller from the run's own record — + * the runner knows only which run and which locale, so none of this reaches it. + */ +export type TranslateDocumentInput = { + collection: CollectionSlug; + collectionId: ID; + sourceLng: string; + targetLng: string; + filters?: readonly TranslationFilter[]; + publishOnTranslation: boolean; + previousAttempts: number; +}; + export type TranslateDocumentDeps = { readonly translationProvider: TranslationProvider; readonly schemaMap: CollectionSchemaMap; @@ -31,7 +46,7 @@ async function publishIfAsked(run: Run, asked: boolean): Promise { export async function translateDocument( deps: TranslateDocumentDeps, payload: Payload, - input: TaskHandlerInput, + input: TranslateDocumentInput, scope: RequestScope ): Promise { const modules = deps.modules ?? []; diff --git a/packages/payload-plugin-translator/src/server/features/translate-document/wireTranslateRunner.ts b/packages/payload-plugin-translator/src/server/features/translate-document/wireTranslateRunner.ts index ea86819a0..a6bf7c597 100644 --- a/packages/payload-plugin-translator/src/server/features/translate-document/wireTranslateRunner.ts +++ b/packages/payload-plugin-translator/src/server/features/translate-document/wireTranslateRunner.ts @@ -1,69 +1,163 @@ -import type { CollectionSlug } from "payload"; +import type { Payload } from "payload"; -import type { TranslationProvider } from "../../../core/domain/translation-providers/index.js"; -import type { CollectionSchemaMap } from "../../../types/CollectionSchemaMap.js"; import type { ConfigModifier } from "../../../types/ConfigModifier.js"; -import type { ChainMember, Criteria } from "../../modules/translation-run/index.js"; -import { LifecycleNotifier, taskFromAssignment } from "../../modules/lifecycle/index.js"; -import type { TranslationLifecycleCallbacks } from "../../modules/lifecycle/index.js"; +import { LifecycleNotifier } from "../../modules/lifecycle/index.js"; +import type { + TranslationLifecycleCallbacks, + TranslationTask, +} from "../../modules/lifecycle/index.js"; +import { filtersFromStore, RunRecordMissing, storedFilters } from "../../modules/runs/index.js"; +import type { RunRecord, RunRecordStoreFactory } from "../../modules/runs/index.js"; +import type { RunOutcome } from "../../modules/runs/RunRecord.types.js"; import type { TaskRunnerContext, TaskRunnerFactory, TaskRunnerProvider, } from "../../modules/task-runner/index.js"; +import { toClientErrorMessage } from "../../shared/http/index.js"; +import type { RequestScope } from "../../shared/payload/RequestScope.shapes.js"; +import { asRequester } from "../../shared/payload/RequestScope.shapes.js"; +import { swallowOrThrow } from "../../shared/payload/swallowOrThrow.js"; import { translateDocument } from "./translateDocument.js"; +import type { TranslateDocumentDeps, TranslateDocumentInput } from "./translateDocument.js"; type WireTranslateRunnerParams = { - translationProvider: TranslationProvider; - schemaMap: CollectionSchemaMap; - modules: readonly ChainMember[]; - criteria?: Criteria; + translation: TranslateDocumentDeps; runner: TaskRunnerProvider; lifecycle: TranslationLifecycleCallbacks; - collections: CollectionSlug[]; + storeFactory: RunRecordStoreFactory; }; +const now = (): string => new Date().toISOString(); + +const requesterIdOf = (scope: RequestScope): string | null => + scope.requester == null ? null : String(scope.requester.userId); + +/** Only a message is ours to store; the error itself may carry anything the provider put on it. */ +function messageOf(error: unknown): string | undefined { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + return undefined; +} + +const whatToTranslate = (row: RunRecord, previousAttempts: number): TranslateDocumentInput => ({ + collection: row.collectionSlug, + collectionId: row.collectionId, + sourceLng: row.sourceLng, + targetLng: row.targetLng, + filters: filtersFromStore(row.filters), + publishOnTranslation: row.publishOnTranslation, + previousAttempts, +}); + +const documentOf = (work: { collectionSlug: string; collectionId: string; sourceLng: string }) => ({ + collection: work.collectionSlug, + id: work.collectionId, + sourceLng: work.sourceLng, +}); + +const taskFromRow = (row: RunRecord): TranslationTask => ({ + collection: row.collectionSlug, + id: row.collectionId, + sourceLng: row.sourceLng, + targetLng: row.targetLng, + handle: row.handle, +}); + /** - * Assemble the document-translation task pipeline: the runner context that hands each job to - * {@link translateDocument} and turns what the runner reports into the host's callbacks, the - * runner's config modifier, and the per-request {@link TaskRunnerFactory}. + * The write policy for every row except the queued one: a lost stamp or settlement costs a + * timestamp, which is not worth failing a translation the host has already paid for. + */ +function writeOrLog( + payload: Payload, + scope: RequestScope, + write: () => Promise +): Promise { + return swallowOrThrow(scope, write, (err) => + payload.logger?.error?.({ err, msg: "[translator] the run record was not updated" }) + ); +} + +/** + * Assembles the document-translation pipeline: the runner context, the runner's config modifier and + * the per-request {@link TaskRunnerFactory}. + * + * The record is written before the host's callback in every branch: the callback is best-effort by + * contract, the record is what the status, cancel and run-now routes answer from. */ export function wireTranslateRunner({ - translationProvider, - schemaMap, - modules, - criteria, + translation, runner, lifecycle, - collections, + storeFactory, }: WireTranslateRunnerParams): { taskRunnerFactory: TaskRunnerFactory; configModifier: ConfigModifier; } { - const deps = { translationProvider, schemaMap, modules, criteria }; - const runnerContext: TaskRunnerContext = { - handler: (payload, input, scope) => translateDocument(deps, payload, input, scope ?? {}), - collections, - report: (payload, assignment, event) => { + handler: async (payload, input, scope) => { + const asked = scope ?? {}; + const runs = storeFactory(payload, asked); + const key = { handle: input.handle, targetLng: input.targetLng }; + const row = await runs.find(key); + if (row === null) throw new RunRecordMissing(key.handle, key.targetLng); + + await writeOrLog(payload, asked, () => + runs.recordStarted(key, { startedAt: now(), attempts: input.previousAttempts + 1 }) + ); + + await translateDocument(translation, payload, whatToTranslate(row, input.previousAttempts), { + ...asked, + requester: asRequester(row.requesterId, row.requesterCollection), + }); + }, + report: async (payload, run, event, scope) => { + const asked = scope ?? {}; + const runs = storeFactory(payload, asked); const notifier = new LifecycleNotifier(lifecycle, payload.logger); - const task = taskFromAssignment(assignment); + + if (event.state === "queued") { + await runs.recordQueued({ + handle: run.handle, + targetLng: run.targetLng, + collectionSlug: event.work.collectionSlug, + collectionId: event.work.collectionId, + sourceLng: event.work.sourceLng, + filters: storedFilters(event.work.filters), + publishOnTranslation: event.work.publishOnTranslation, + requesterId: requesterIdOf(asked), + requesterCollection: asked.requester?.userCollection ?? null, + queuedAt: now(), + }); + return await notifier.queued({ ...run, ...documentOf(event.work) }); + } + + const settle = (outcome: RunOutcome, error: string | null) => + writeOrLog(payload, asked, () => + runs.recordSettled(run, { settledAt: now(), outcome, error }) + ); + + const row = await runs.find(run); + const task = row === null ? null : taskFromRow(row); + switch (event.state) { - case "queued": - return notifier.queued(task); case "delivered": - return notifier.completed(task); + await settle("delivered", null); + return await (task === null ? undefined : notifier.completed(task)); case "failed": - return notifier.failed(task, event.error); + await settle("failed", toClientErrorMessage(messageOf(event.error))); + return await (task === null ? undefined : notifier.failed(task, event.error)); case "cancelled": - return notifier.cancelled(task); + await settle("cancelled", null); + return await (task === null ? undefined : notifier.cancelled(task)); } }, }; const taskRunnerFactory: TaskRunnerFactory = { create: (payload) => runner.create(payload, runnerContext), + capabilities: runner.capabilities, }; return { taskRunnerFactory, configModifier: runner.configure(runnerContext) }; diff --git a/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslate.wiring.ts b/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslate.wiring.ts index 54df4fa4e..2caf0b8b1 100644 --- a/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslate.wiring.ts +++ b/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslate.wiring.ts @@ -6,6 +6,7 @@ import { Locales } from "../../../core/domain/locales/index.js"; import type { CollectionSchemaMap } from "../../../types/CollectionSchemaMap.js"; import type { ConfigModifier } from "../../../types/ConfigModifier.js"; import type { TaskRunnerFactory } from "../task-runner/index.js"; +import type { RunRecordStoreFactory } from "../runs/index.js"; import { filterPolicyToKnownLocales, @@ -101,7 +102,8 @@ function warnAboutTheRemovedStrategy(collections: ConfigurableCollection[]): voi export function configureAutoTranslate( collections: ConfigurableCollection[], schemaMap: CollectionSchemaMap, - taskRunnerFactory: TaskRunnerFactory + taskRunnerFactory: TaskRunnerFactory, + runRecordStoreFactory: RunRecordStoreFactory ): AutoTranslateModule { warnAboutTheRemovedStrategy(collections); @@ -114,7 +116,12 @@ export function configureAutoTranslate( const enabledSlugs = new Set(policies.keys()); const resolvePolicy = makeCollectionPolicyResolver(policies); - const hook = makeAutoTranslateHook({ resolvePolicy, schemaMap, taskRunnerFactory }); + const hook = makeAutoTranslateHook({ + resolvePolicy, + schemaMap, + taskRunnerFactory, + runRecordStoreFactory, + }); return { configure: diff --git a/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslateEnqueue.hook.ts b/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslateEnqueue.hook.ts index b503ff4a3..b49b7058d 100644 --- a/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslateEnqueue.hook.ts +++ b/packages/payload-plugin-translator/src/server/modules/auto-translate/AutoTranslateEnqueue.hook.ts @@ -1,4 +1,4 @@ -import type { CollectionAfterChangeHook } from "payload"; +import type { CollectionAfterChangeHook, CollectionSlug } from "payload"; import { hasDraftsEnabled } from "payload/shared"; import { authCollectionsOf, identityOf, scopeOf } from "../../shared/payload/identityOf.js"; @@ -11,6 +11,8 @@ import { AUTO_TRANSLATE_CUSTOM_KEY } from "../../../core/domain/auto-translate/i import { AUTO_TRANSLATE_SKIP_CONTEXT_KEY } from "../../../types/AutoTranslateContext.js"; import type { CollectionSchemaMap } from "../../../types/CollectionSchemaMap.js"; import type { TaskRunnerFactory } from "../task-runner/index.js"; +import type { RunRecordStoreFactory } from "../runs/index.js"; +import { admitWork } from "../../../core/domain/runs/index.js"; import type { AutoTranslatePolicyResolver, @@ -26,15 +28,20 @@ type AutoTranslateHookDeps = { resolvePolicy: AutoTranslatePolicyResolver; schemaMap: CollectionSchemaMap; taskRunnerFactory: TaskRunnerFactory; + runRecordStoreFactory: RunRecordStoreFactory; }; /** `afterChange` hook that enqueues translations when a document's source-locale content changes. */ export function makeAutoTranslateHook(deps: AutoTranslateHookDeps): CollectionAfterChangeHook { - const { resolvePolicy, schemaMap, taskRunnerFactory } = deps; + const { resolvePolicy, schemaMap, taskRunnerFactory, runRecordStoreFactory } = deps; const hook: MarkedHook = async ({ doc, previousDoc, req, collection }) => { const transactionID = await req.transactionID; const scope: RequestScope = transactionID == null ? {} : { transactionID }; + const about = { collection: collection.slug, documentId: String(doc.id) }; + const skipBecause = (msg: string) => { + req.payload.logger.warn({ ...about, msg }); + }; await swallowOrThrow( scope, @@ -50,12 +57,9 @@ export function makeAutoTranslateHook(deps: AutoTranslateHookDeps): CollectionAf const sourceLocale = policy.sourceLocale ?? (localization ? localization.defaultLocale : undefined); if (!sourceLocale) { - req.payload.logger.warn({ - collection: collection.slug, - documentId: String(doc.id), - msg: "translator: auto-translate skipped — no source locale resolvable (set localization.defaultLocale or a per-collection sourceLocale)", - }); - return; + return skipBecause( + "translator: auto-translate skipped — no source locale resolvable (set localization.defaultLocale or a per-collection sourceLocale)" + ); } if (req.locale !== sourceLocale) return; @@ -79,26 +83,33 @@ export function makeAutoTranslateHook(deps: AutoTranslateHookDeps): CollectionAf const identity = identityOf(req, authCollectionsOf(req.payload), req.payload.logger); if (identity.kind === "unnameable") { - // Skipped, not thrown: inside the save's transaction `swallowOrThrow` rethrows, which - // rolls back the editor's save. - req.payload.logger.warn({ - collection: collection.slug, - documentId: String(doc.id), - msg: "translator: auto-translate skipped, because the save carries an actor this project cannot name", - }); - return; + return skipBecause( + "translator: auto-translate skipped, because the save carries an actor this project cannot name" + ); } - await taskRunnerFactory.create(req.payload).enqueue(authorisedByTheSave(tasks), { + const { admitted, busyDocumentIds: busy } = admitWork({ + work: authorisedByTheSave(tasks), + inFlight: await runRecordStoreFactory(req.payload, scope).findLiveForDocuments( + collection.slug as CollectionSlug, + [String(doc.id)] + ), + }); + if (busy.length > 0) { + return skipBecause( + "translator: auto-translate skipped, because this document already has a translation run under way" + ); + } + + await taskRunnerFactory.create(req.payload).enqueue(admitted, { ...scope, ...scopeOf(identity), }); }, (error) => req.payload.logger.error({ + ...about, err: error, - collection: collection.slug, - documentId: String(doc.id), msg: "translator: auto-translate hook failed", }) ); diff --git a/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslate.wiring.test.ts b/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslate.wiring.test.ts index 302c652ac..ea6a08cf6 100644 --- a/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslate.wiring.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslate.wiring.test.ts @@ -5,6 +5,7 @@ import { configureAutoTranslate } from "../AutoTranslate.wiring.js"; import { AUTO_TRANSLATE_CUSTOM_KEY } from "../../../../core/domain/auto-translate/index.js"; import type { CollectionSchemaMap } from "../../../../types/CollectionSchemaMap.js"; import type { TaskRunnerFactory } from "../../task-runner/index.js"; +import { storeDouble } from "../../runs/__tests__/fixtures/runRecordStore.js"; const collectionWith = (targets: string[]) => ({ slug: "posts", @@ -21,7 +22,8 @@ const buildConfig = (locales: string[] | false, targets: string[]) => { const autoTranslate = configureAutoTranslate( [collection], new Map() as CollectionSchemaMap, - (() => ({})) as unknown as TaskRunnerFactory + (() => ({})) as unknown as TaskRunnerFactory, + () => storeDouble() ); autoTranslate.configure(new Set(["posts"]))(config); diff --git a/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslateEnqueue.hook.test.ts b/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslateEnqueue.hook.test.ts index 023e9f3d9..51df519da 100644 --- a/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslateEnqueue.hook.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslateEnqueue.hook.test.ts @@ -17,6 +17,7 @@ import { makeAutoTranslateHook, propagateAutoTranslateCustom, } from "../AutoTranslateEnqueue.hook.js"; +import { storeDouble } from "../../runs/__tests__/fixtures/runRecordStore.js"; const schema: Field[] = [{ name: "title", type: "text", localized: true }]; const schemaMap: CollectionSchemaMap = new Map([["posts" as CollectionSlug, schema]]); @@ -31,17 +32,22 @@ function setup( opts: { policies?: Map; enqueue?: ReturnType; + inFlight?: unknown[]; } = {} ) { const enqueue = opts.enqueue ?? vi.fn().mockResolvedValue(undefined); const taskRunnerFactory = { create: vi.fn().mockReturnValue({ enqueue }) }; const resolvePolicy = makeCollectionPolicyResolver(opts.policies ?? new Map([["posts", policy]])); + const runs = storeDouble({ + findLiveForDocuments: vi.fn().mockResolvedValue(opts.inFlight ?? []), + }); const hook = makeAutoTranslateHook({ resolvePolicy, schemaMap, taskRunnerFactory: taskRunnerFactory as never, + runRecordStoreFactory: () => runs, }); - return { hook, enqueue }; + return { hook, enqueue, runs }; } // A CollectionAfterChangeHook args object with sensible defaults (published source-locale update). @@ -227,4 +233,46 @@ describe("propagateAutoTranslateCustom", () => { propagateAutoTranslateCustom(config, new Set(["posts"]), policies); expect(getAutoTranslateConfig(config.collections[0])).toBeNull(); }); + + describe("a document already being translated", () => { + it("queues nothing, because a second run would put the first run's locales back", async () => { + const { hook, enqueue } = setup({ + inFlight: [{ collectionSlug: "posts", collectionId: "1" }], + }); + + await hook(hookArgs()); + + expect(enqueue).not.toHaveBeenCalled(); + }); + + it("leaves the editor's save alone, even inside their transaction, and says why in the log", async () => { + const { hook } = setup({ inFlight: [{ collectionSlug: "posts", collectionId: "1" }] }); + + const answer = await hook(hookArgs({ transactionID: "tx-1" })); + + expect(answer, "the hook answers with the document, so the save goes through").toMatchObject({ + id: "1", + }); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ documentId: "1", collection: "posts" }) + ); + expect(logger.error, "nothing failed; it was simply not queued").not.toHaveBeenCalled(); + }); + + it("asks only about the document that was saved", async () => { + const { hook, runs } = setup({ inFlight: [] }); + + await hook(hookArgs()); + + expect(runs.findLiveForDocuments).toHaveBeenCalledWith("posts", ["1"]); + }); + + it("queues as usual once nothing is under way", async () => { + const { hook, enqueue } = setup({ inFlight: [] }); + + await hook(hookArgs()); + + expect(enqueue).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslateEnqueue.identity.test.ts b/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslateEnqueue.identity.test.ts index 47b69b78a..37c314006 100644 --- a/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslateEnqueue.identity.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslateEnqueue.identity.test.ts @@ -6,6 +6,7 @@ import type { TaskRunnerFactory } from "../../task-runner/TaskRunnerProvider.int import { makeCollectionPolicyResolver } from "../AutoTranslate.policy.js"; import type { NormalizedAutoTranslatePolicy } from "../AutoTranslate.policy.js"; import { makeAutoTranslateHook } from "../AutoTranslateEnqueue.hook.js"; +import { storeDouble } from "../../runs/__tests__/fixtures/runRecordStore.js"; const schemaMap: CollectionSchemaMap = new Map([ ["posts" as CollectionSlug, [{ name: "title", type: "text", localized: true }] as Field[]], @@ -25,6 +26,7 @@ function setup() { resolvePolicy: makeCollectionPolicyResolver(new Map([["posts", policy]])), schemaMap, taskRunnerFactory, + runRecordStoreFactory: () => storeDouble(), }); return { hook, enqueue }; } diff --git a/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslateEnqueue.transaction.test.ts b/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslateEnqueue.transaction.test.ts index a5f1f6375..3c6595978 100644 --- a/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslateEnqueue.transaction.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/AutoTranslateEnqueue.transaction.test.ts @@ -6,6 +6,7 @@ import type { TaskRunnerFactory } from "../../task-runner/TaskRunnerProvider.int import { makeCollectionPolicyResolver } from "../AutoTranslate.policy.js"; import type { NormalizedAutoTranslatePolicy } from "../AutoTranslate.policy.js"; import { makeAutoTranslateHook } from "../AutoTranslateEnqueue.hook.js"; +import { storeDouble } from "../../runs/__tests__/fixtures/runRecordStore.js"; const schemaMap: CollectionSchemaMap = new Map([ ["posts" as CollectionSlug, [{ name: "title", type: "text", localized: true }] as Field[]], @@ -26,6 +27,7 @@ function setup() { resolvePolicy: makeCollectionPolicyResolver(new Map([["posts", policy]])), schemaMap, taskRunnerFactory, + runRecordStoreFactory: () => storeDouble(), }); return { hook, enqueue }; } diff --git a/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/legacyStrategyWarning.test.ts b/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/legacyStrategyWarning.test.ts index 62fa5f705..a20502fc0 100644 --- a/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/legacyStrategyWarning.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/auto-translate/__tests__/legacyStrategyWarning.test.ts @@ -4,6 +4,7 @@ import { AUTO_TRANSLATE_CUSTOM_KEY } from "../../../../core/domain/auto-translat import type { CollectionSchemaMap } from "../../../../types/CollectionSchemaMap.js"; import type { TaskRunnerFactory } from "../../task-runner/index.js"; import { configureAutoTranslate } from "../AutoTranslate.wiring.js"; +import { storeDouble } from "../../runs/__tests__/fixtures/runRecordStore.js"; const runner = { create: vi.fn() } as unknown as TaskRunnerFactory; const schemaMap = new Map() as CollectionSchemaMap; @@ -21,7 +22,8 @@ describe("a collection still carrying the removed auto-translate option", () => configureAutoTranslate( collection({ [AUTO_TRANSLATE_CUSTOM_KEY]: { targets: ["de"], strategy: "skip_existing" } }), schemaMap, - runner + runner, + () => storeDouble() ); expect(warn).toHaveBeenCalledWith(expect.stringContaining("`strategy` was removed")); @@ -33,7 +35,8 @@ describe("a collection still carrying the removed auto-translate option", () => configureAutoTranslate( collection({ [AUTO_TRANSLATE_CUSTOM_KEY]: { targets: ["de"] } }), schemaMap, - runner + runner, + () => storeDouble() ); expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("`strategy` was removed")); diff --git a/packages/payload-plugin-translator/src/server/modules/plugin-config/PluginConfigBuilder.test.ts b/packages/payload-plugin-translator/src/server/modules/plugin-config/PluginConfigBuilder.test.ts index acf6af0fd..3f0d73cf8 100644 --- a/packages/payload-plugin-translator/src/server/modules/plugin-config/PluginConfigBuilder.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/plugin-config/PluginConfigBuilder.test.ts @@ -6,12 +6,14 @@ import type { RawPayloadComponentExport } from "../../../types/PayloadComponentE import { resolveSettings } from "../../../settings.js"; import { PluginConfigBuilder } from "./PluginConfigBuilder.js"; import { AnyAccessGuard } from "../../shared/access/AnyAccessGuard.js"; +import { storeDouble } from "../runs/__tests__/fixtures/runRecordStore.js"; const deps = (collections: Array<{ slug: string }> = []) => ({ collections: collections as unknown as CollectionConfig[], settings: resolveSettings({}), access: new AnyAccessGuard(), - taskRunnerFactory: { create: vi.fn() }, + taskRunnerFactory: { create: vi.fn(), capabilities: { stop: true, run: true } }, + runRecordStoreFactory: () => storeDouble(), schemaMap: new Map(), translationProvider: { translate: vi.fn() }, }); diff --git a/packages/payload-plugin-translator/src/server/modules/plugin-config/PluginConfigBuilder.ts b/packages/payload-plugin-translator/src/server/modules/plugin-config/PluginConfigBuilder.ts index 56e806b5e..7b9a1f716 100644 --- a/packages/payload-plugin-translator/src/server/modules/plugin-config/PluginConfigBuilder.ts +++ b/packages/payload-plugin-translator/src/server/modules/plugin-config/PluginConfigBuilder.ts @@ -6,6 +6,7 @@ import type { CollectionSchemaMap } from "../../../types/CollectionSchemaMap.js" import type { ConfigModifier } from "../../../types/ConfigModifier.js"; import type { TranslationProvider } from "../../../core/domain/translation-providers/index.js"; import type { TaskRunnerFactory } from "../task-runner/index.js"; +import type { RunRecordStoreFactory } from "../runs/index.js"; import type { TranslatorSettings } from "../../../settings.js"; import type { @@ -59,6 +60,7 @@ export class PluginConfigBuilder implements ComponentContext, ModuleContext { readonly settings: TranslatorSettings; readonly access: AccessGuard; readonly taskRunnerFactory: TaskRunnerFactory; + readonly runRecordStoreFactory: RunRecordStoreFactory; readonly schemaMap: CollectionSchemaMap; readonly translationProvider: TranslationProvider; @@ -72,6 +74,7 @@ export class PluginConfigBuilder implements ComponentContext, ModuleContext { this.settings = deps.settings; this.access = deps.access; this.taskRunnerFactory = deps.taskRunnerFactory; + this.runRecordStoreFactory = deps.runRecordStoreFactory; this.schemaMap = deps.schemaMap; this.translationProvider = deps.translationProvider; } diff --git a/packages/payload-plugin-translator/src/server/modules/plugin-config/types.ts b/packages/payload-plugin-translator/src/server/modules/plugin-config/types.ts index c10027ded..9e50cf158 100644 --- a/packages/payload-plugin-translator/src/server/modules/plugin-config/types.ts +++ b/packages/payload-plugin-translator/src/server/modules/plugin-config/types.ts @@ -6,6 +6,7 @@ import type { RawPayloadComponentExport } from "../../../types/PayloadComponentE import type { CollectionSchemaMap } from "../../../types/CollectionSchemaMap.js"; import type { TranslationProvider } from "../../../core/domain/translation-providers/index.js"; import type { TaskRunnerFactory } from "../task-runner/index.js"; +import type { RunRecordStoreFactory } from "../runs/index.js"; import type { ChainMember } from "../translation-run/index.js"; import type { TranslatorSettings } from "../../../settings.js"; @@ -16,6 +17,7 @@ export type ConfigTimeContext = { readonly settings: TranslatorSettings; readonly access: AccessGuard; readonly taskRunnerFactory: TaskRunnerFactory; + readonly runRecordStoreFactory: RunRecordStoreFactory; readonly schemaMap: CollectionSchemaMap; readonly translationProvider: TranslationProvider; }; diff --git a/packages/payload-plugin-translator/src/server/modules/runs/RunRecord.collection.ts b/packages/payload-plugin-translator/src/server/modules/runs/RunRecord.collection.ts new file mode 100644 index 000000000..deff32b0f --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/runs/RunRecord.collection.ts @@ -0,0 +1,64 @@ +import type { CollectionConfig } from "payload"; + +import type { ManagedCollectionsConfig } from "./RunRecord.shapes.js"; + +export const DEFAULT_RUNS_SLUG = "translator-runs"; + +const RUNS_MARKER = "translatorRuns"; + +/** + * The plugin's own record of translation runs, one row per locale. A run covers several locales, so + * `(handle, targetLng)` — not the handle — names a row, and that unique index is the upsert key. + * `settledAt` carries its own index: "still owed" is the question these rows are read with. + */ +export function makeRunRecordCollection(slug: string = DEFAULT_RUNS_SLUG): CollectionConfig { + return { + slug, + admin: { hidden: true }, + access: { + read: () => false, + create: () => false, + update: () => false, + delete: () => false, + }, + custom: { [RUNS_MARKER]: true }, + fields: [ + { name: "handle", type: "text", required: true, index: true }, + { name: "collectionSlug", type: "text", required: true }, + { name: "collectionId", type: "text", required: true }, + { name: "sourceLng", type: "text", required: true }, + { name: "targetLng", type: "text", required: true }, + { name: "filters", type: "text" }, + { name: "publishOnTranslation", type: "checkbox", required: true }, + { name: "requesterId", type: "text" }, + { name: "requesterCollection", type: "text" }, + { name: "queuedAt", type: "date", required: true }, + { name: "startedAt", type: "date" }, + { name: "settledAt", type: "date", index: true }, + { name: "outcome", type: "text" }, + { name: "error", type: "text" }, + { name: "attempts", type: "number" }, + ], + indexes: [ + { fields: ["handle", "targetLng"], unique: true }, + { fields: ["collectionSlug", "collectionId"] }, + ], + }; +} + +/** True for the plugin's own record. Keyed on `custom`, not the slug, which the consumer may change. */ +export function isRunRecordCollection(collection: { custom?: unknown }): boolean { + return (collection.custom as Record | undefined)?.[RUNS_MARKER] === true; +} + +export function ensureRunRecordCollectionRegistered( + host: ManagedCollectionsConfig, + slug: string +): void { + const alreadyAdded = host.collections?.some( + (collection) => collection.slug === slug && isRunRecordCollection(collection) + ); + if (!alreadyAdded) { + host.collections = [...(host.collections ?? []), makeRunRecordCollection(slug)]; + } +} diff --git a/packages/payload-plugin-translator/src/server/modules/runs/RunRecord.shapes.ts b/packages/payload-plugin-translator/src/server/modules/runs/RunRecord.shapes.ts new file mode 100644 index 000000000..35d103fc1 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/runs/RunRecord.shapes.ts @@ -0,0 +1,5 @@ +import type { CollectionConfig } from "payload"; + +export type ManagedCollectionsConfig = { + collections?: Array>; +}; diff --git a/packages/payload-plugin-translator/src/server/modules/runs/RunRecord.store.ts b/packages/payload-plugin-translator/src/server/modules/runs/RunRecord.store.ts new file mode 100644 index 000000000..00ba2cfea --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/runs/RunRecord.store.ts @@ -0,0 +1,253 @@ +import type { CollectionSlug, Payload, Where } from "payload"; + +import type { ID, JobHandle } from "../task-runner/types.js"; + +import type { RequestScope } from "../../shared/payload/RequestScope.shapes.js"; +import { freshReq } from "../../shared/payload/RequestScope.shapes.js"; + +import type { + RunDocument, + RunIdentity, + RunOutcome, + RunRecord, + RunRecordKey, + RunSettlement, + RunStart, +} from "./RunRecord.types.js"; + +export type RunRecordStoreFactory = (payload: Payload, scope?: RequestScope) => RunRecordStore; + +export interface RunRecordStore { + /** + * Record that this locale is queued. A key already recorded keeps its row — unless that row is + * about a different document, which means the handle was reused and the old row is overwritten. + */ + recordQueued(identity: RunIdentity): Promise; + /** A row that is not there is not created. */ + recordStarted(key: RunRecordKey, start: RunStart): Promise; + /** + * Close the row. A row that is not there is not created, and the later settlement wins: a locale + * cancelled while its work was in flight still records what the work did. + */ + recordSettled(key: RunRecordKey, settlement: RunSettlement): Promise; + find(key: RunRecordKey): Promise; + findByDocument(document: RunDocument): Promise; + findByHandles(handles: readonly JobHandle[]): Promise; + /** Live means queued or running: `settledAt` is empty. */ + findLiveInCollection(collectionSlug: CollectionSlug): Promise; + findLiveForDocuments(collectionSlug: CollectionSlug, ids: readonly ID[]): Promise; + findRecentlyFailedInCollection(collectionSlug: CollectionSlug, since: Date): Promise; + deleteByDocument(document: RunDocument): Promise; +} + +/** + * Nothing prunes this table, so every read states its own ceiling — and a read that would answer + * with more rows is truncated silently, not reported. + */ +const MOST_ROWS_ONE_READ_ANSWERS = 500; + +const noProgressYet = { + startedAt: null, + settledAt: null, + outcome: null, + error: null, + attempts: null, +}; + +interface RunRecordDoc extends Record { + id: string | number; +} + +function keyWhere(key: RunRecordKey): Where { + return { + and: [{ handle: { equals: key.handle } }, { targetLng: { equals: key.targetLng } }], + }; +} + +const isAbout = (row: RunRecordDoc, identity: RunIdentity): boolean => + String(row.collectionSlug) === identity.collectionSlug && + String(row.collectionId) === identity.collectionId; + +function documentWhere(document: RunDocument): Where { + return { + and: [ + { collectionSlug: { equals: document.collectionSlug } }, + { collectionId: { equals: document.collectionId } }, + ], + }; +} + +const text = (value: unknown): string | null => (value == null ? null : String(value)); + +const moment = (value: unknown): string | null => + value == null ? null : new Date(value as string | number | Date).toISOString(); + +function toRecord(doc: RunRecordDoc): RunRecord { + return { + handle: String(doc.handle), + collectionSlug: String(doc.collectionSlug) as CollectionSlug, + collectionId: String(doc.collectionId), + sourceLng: String(doc.sourceLng), + targetLng: String(doc.targetLng), + filters: text(doc.filters), + publishOnTranslation: doc.publishOnTranslation === true, + requesterId: text(doc.requesterId), + requesterCollection: text(doc.requesterCollection), + queuedAt: moment(doc.queuedAt) ?? new Date(0).toISOString(), + startedAt: moment(doc.startedAt), + settledAt: moment(doc.settledAt), + outcome: text(doc.outcome) as RunOutcome | null, + error: text(doc.error), + attempts: doc.attempts == null ? null : Number(doc.attempts), + }; +} + +/** + * Payload-backed store for the run record. The collection is built at config time, so no generated + * type names it — hence the one `CollectionSlug` cast; {@link makeRunRecordCollection} is what makes + * the shape true. + */ +export class PayloadRunRecordStore implements RunRecordStore { + private readonly payload: Payload; + private readonly collection: CollectionSlug; + private readonly scope: RequestScope; + + constructor(payload: Payload, slug: string, scope: RequestScope = {}) { + this.payload = payload; + this.collection = slug as CollectionSlug; + this.scope = scope; + } + + private req(): { transactionID?: string | number } { + return freshReq(this.scope); + } + + async recordQueued(identity: RunIdentity): Promise { + const existing = await this.findDoc(identity); + if (existing !== null) { + if (isAbout(existing, identity)) return; + // A SQL store reissues a deleted job's id, so a handle can come round again naming a different + // document; the row found here belongs to a run that no longer exists. + await this.payload.update({ + req: this.req(), + collection: this.collection, + id: existing.id, + data: { ...identity, ...noProgressYet }, + }); + return; + } + + try { + await this.payload.create({ + req: this.req(), + collection: this.collection, + data: { ...identity, ...noProgressYet }, + }); + } catch (error) { + await this.ignoreIfTheRowIsNowThere(error, identity); + } + } + + recordStarted(key: RunRecordKey, start: RunStart): Promise { + return this.patch(key, start); + } + + recordSettled(key: RunRecordKey, settlement: RunSettlement): Promise { + return this.patch(key, settlement); + } + + async find(key: RunRecordKey): Promise { + const doc = await this.findDoc(key); + return doc === null ? null : toRecord(doc); + } + + findByDocument(document: RunDocument): Promise { + return this.rows(documentWhere(document)); + } + + async findByHandles(handles: readonly JobHandle[]): Promise { + if (handles.length === 0) return []; + return await this.rows({ handle: { in: [...handles] } }); + } + + findLiveInCollection(collectionSlug: CollectionSlug): Promise { + return this.rows({ + and: [{ collectionSlug: { equals: collectionSlug } }, { settledAt: { exists: false } }], + }); + } + + async findLiveForDocuments( + collectionSlug: CollectionSlug, + ids: readonly ID[] + ): Promise { + if (ids.length === 0) return []; + return await this.rows({ + and: [ + { collectionSlug: { equals: collectionSlug } }, + { collectionId: { in: [...ids] } }, + { settledAt: { exists: false } }, + ], + }); + } + + findRecentlyFailedInCollection( + collectionSlug: CollectionSlug, + since: Date + ): Promise { + return this.rows({ + and: [ + { collectionSlug: { equals: collectionSlug } }, + { outcome: { equals: "failed" } }, + { settledAt: { greater_than: since.toISOString() } }, + ], + }); + } + + /** + * Deliberately outside the caller's transaction: this runs from the host's own `afterDelete`, and + * a failed cleanup must never roll back the document delete that triggered it. + */ + async deleteByDocument(document: RunDocument): Promise { + await this.payload.delete({ + collection: this.collection, + where: documentWhere(document), + }); + } + + private async rows(where: Where): Promise { + const result = await this.payload.find({ + req: this.req(), + collection: this.collection, + where, + depth: 0, + limit: MOST_ROWS_ONE_READ_ANSWERS, + sort: "-queuedAt", + }); + return (result.docs as RunRecordDoc[]).map(toRecord); + } + + private async patch(key: RunRecordKey, data: Record): Promise { + await this.payload.update({ + req: this.req(), + collection: this.collection, + where: keyWhere(key), + data, + }); + } + + private async ignoreIfTheRowIsNowThere(error: unknown, key: RunRecordKey): Promise { + if ((await this.findDoc(key)) === null) throw error; + } + + private async findDoc(key: RunRecordKey): Promise { + const result = await this.payload.find({ + req: this.req(), + collection: this.collection, + where: keyWhere(key), + limit: 1, + depth: 0, + pagination: false, + }); + return (result.docs[0] as RunRecordDoc | undefined) ?? null; + } +} diff --git a/packages/payload-plugin-translator/src/server/modules/runs/RunRecord.types.ts b/packages/payload-plugin-translator/src/server/modules/runs/RunRecord.types.ts new file mode 100644 index 000000000..14dc2ce4c --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/runs/RunRecord.types.ts @@ -0,0 +1,47 @@ +import type { CollectionSlug } from "payload"; + +import type { ID, JobHandle } from "../task-runner/types.js"; + +export type RunOutcome = "delivered" | "failed" | "cancelled"; + +export type RunIdentity = { + handle: JobHandle; + collectionSlug: CollectionSlug; + collectionId: ID; + sourceLng: string; + targetLng: string; + filters: string | null; + publishOnTranslation: boolean; + requesterId: string | null; + requesterCollection: string | null; + queuedAt: string; +}; + +export type RunProgress = { + startedAt: string | null; + settledAt: string | null; + outcome: RunOutcome | null; + error: string | null; + attempts: number | null; +}; + +export type RunRecord = RunIdentity & RunProgress; + +export type RunRecordKey = Pick; + +export type RunDocument = Pick; + +export type RunStart = { + startedAt: string; + attempts: number; +}; + +/** + * What became of one locale, and when the plugin was told — `TaskEvent` carries no timestamp of its + * own, so `settledAt` is the telling, not the happening. + */ +export type RunSettlement = { + settledAt: string; + outcome: RunOutcome; + error: string | null; +}; diff --git a/packages/payload-plugin-translator/src/server/modules/runs/RunRecord.wiring.ts b/packages/payload-plugin-translator/src/server/modules/runs/RunRecord.wiring.ts new file mode 100644 index 000000000..6ae5c9980 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/runs/RunRecord.wiring.ts @@ -0,0 +1,49 @@ +import type { ConfigModifier } from "../../../types/ConfigModifier.js"; + +import { + DEFAULT_RUNS_SLUG, + ensureRunRecordCollectionRegistered, + isRunRecordCollection, +} from "./RunRecord.collection.js"; +import { injectRunRecordCleanup } from "./RunRecordCleanup.hook.js"; +import { PayloadRunRecordStore } from "./RunRecord.store.js"; +import type { RunRecordStoreFactory } from "./RunRecord.store.js"; +import { assertRunRecordSlugFree } from "./slugGuard.js"; + +export type RunsOption = { slug?: string } | undefined; + +export type RunRecordContribution = { + storeFactory: RunRecordStoreFactory; + refuseACollidingSlug: ConfigModifier; + addTheCollection: ConfigModifier; + cleanUpOnDelete: (managedSlugs: Set) => ConfigModifier; +}; + +export function configureRunRecord(option: RunsOption): RunRecordContribution { + const slug = option?.slug || DEFAULT_RUNS_SLUG; + + const storeFactory: RunRecordStoreFactory = (payload, scope) => + new PayloadRunRecordStore(payload, slug, scope); + + const refuseACollidingSlug: ConfigModifier = (config) => { + const theHostsOwn = (config.collections ?? []).filter( + (collection) => !isRunRecordCollection(collection) + ); + assertRunRecordSlugFree(slug, theHostsOwn); + return config; + }; + + const addTheCollection: ConfigModifier = (config) => { + ensureRunRecordCollectionRegistered(config, slug); + return config; + }; + + const cleanUpOnDelete = + (managedSlugs: Set): ConfigModifier => + (config) => { + injectRunRecordCleanup(config, managedSlugs, storeFactory, slug); + return config; + }; + + return { storeFactory, refuseACollidingSlug, addTheCollection, cleanUpOnDelete }; +} diff --git a/packages/payload-plugin-translator/src/server/modules/runs/RunRecordCleanup.hook.ts b/packages/payload-plugin-translator/src/server/modules/runs/RunRecordCleanup.hook.ts new file mode 100644 index 000000000..ffe370f98 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/runs/RunRecordCleanup.hook.ts @@ -0,0 +1,56 @@ +import type { CollectionAfterDeleteHook, CollectionSlug } from "payload"; + +import type { ManagedCollectionsConfig } from "./RunRecord.shapes.js"; +import type { RunRecordStoreFactory } from "./RunRecord.store.js"; + +/** + * Payload's hook is a bare function with no `custom` bag, so the idempotency marker rides on the + * function itself; it holds the runs slug, so two plugin instances each attach their own. + */ +type MarkedHook = CollectionAfterDeleteHook & { __translatorRunRecordCleanup?: string }; + +/** + * Best-effort: the store is opened with no scope, so the delete runs outside the caller's + * transaction and a failure is logged rather than thrown — cleanup must never roll back the delete. + */ +export function makeRunRecordCleanupHook( + storeFactory: RunRecordStoreFactory, + runsSlug: string +): CollectionAfterDeleteHook { + const hook: MarkedHook = async ({ id, collection, req }) => { + try { + const store = storeFactory(req.payload); + await store.deleteByDocument({ + collectionSlug: collection.slug as CollectionSlug, + collectionId: String(id), + }); + } catch (error) { + req.payload.logger.error({ + err: error, + collection: collection.slug, + documentId: String(id), + msg: "translator: failed to clean up run records after document delete", + }); + } + }; + hook.__translatorRunRecordCleanup = runsSlug; + return hook; +} + +export function injectRunRecordCleanup( + config: ManagedCollectionsConfig, + managedSlugs: Set, + storeFactory: RunRecordStoreFactory, + runsSlug: string +): void { + const hook = makeRunRecordCleanupHook(storeFactory, runsSlug); + for (const collection of config.collections ?? []) { + if (!managedSlugs.has(collection.slug)) continue; + collection.hooks ??= {}; + collection.hooks.afterDelete ??= []; + const alreadyInjected = collection.hooks.afterDelete.some( + (existing) => (existing as MarkedHook).__translatorRunRecordCleanup === runsSlug + ); + if (!alreadyInjected) collection.hooks.afterDelete.push(hook); + } +} diff --git a/packages/payload-plugin-translator/src/server/modules/runs/RunRecordMissing.ts b/packages/payload-plugin-translator/src/server/modules/runs/RunRecordMissing.ts new file mode 100644 index 000000000..82d6add83 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/runs/RunRecordMissing.ts @@ -0,0 +1,17 @@ +import { TranslatorError } from "../../../core/errors/index.js"; + +/** + * The run record has no row for a locale the runner is translating: the row went while the queue + * still held the work — with the document, through the `afterDelete` cascade, or by pruning. + * `mustPropagate` is `false`: nothing had been written, so the translation is lost and no save is. + */ +export class RunRecordMissing extends TranslatorError { + readonly mustPropagate = false; + + constructor(handle: string, targetLng: string) { + super( + `payload-plugin-translator: no run record for handle "${handle}" and locale "${targetLng}". ` + + "A row with no `settledAt` names work the queue still holds; deleting it strands that work." + ); + } +} diff --git a/packages/payload-plugin-translator/src/server/modules/runs/__tests__/fixtures/runRecordStore.ts b/packages/payload-plugin-translator/src/server/modules/runs/__tests__/fixtures/runRecordStore.ts new file mode 100644 index 000000000..9b639d7c1 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/runs/__tests__/fixtures/runRecordStore.ts @@ -0,0 +1,42 @@ +import { vi } from "vitest"; +import type { CollectionSlug } from "payload"; + +import type { RunRecordStore } from "../../RunRecord.store.js"; +import type { RunRecord } from "../../RunRecord.types.js"; + +export type RunRecordStoreDouble = { + [K in keyof RunRecordStore]: ReturnType; +}; + +export const storeDouble = (over: Partial = {}): RunRecordStoreDouble => ({ + recordQueued: vi.fn().mockResolvedValue(undefined), + recordStarted: vi.fn().mockResolvedValue(undefined), + recordSettled: vi.fn().mockResolvedValue(undefined), + find: vi.fn().mockResolvedValue(null), + findByDocument: vi.fn().mockResolvedValue([]), + findByHandles: vi.fn().mockResolvedValue([]), + findLiveInCollection: vi.fn().mockResolvedValue([]), + findLiveForDocuments: vi.fn().mockResolvedValue([]), + findRecentlyFailedInCollection: vi.fn().mockResolvedValue([]), + deleteByDocument: vi.fn().mockResolvedValue(undefined), + ...over, +}); + +export const runRow = (over: Partial = {}): RunRecord => ({ + handle: "job-1", + collectionSlug: "posts" as CollectionSlug, + collectionId: "doc-1", + sourceLng: "en", + targetLng: "de", + filters: null, + publishOnTranslation: false, + requesterId: null, + requesterCollection: null, + queuedAt: "2026-10-27T10:00:00.000Z", + startedAt: null, + settledAt: null, + outcome: null, + error: null, + attempts: null, + ...over, +}); diff --git a/packages/payload-plugin-translator/src/server/modules/runs/__tests__/registration.test.ts b/packages/payload-plugin-translator/src/server/modules/runs/__tests__/registration.test.ts new file mode 100644 index 000000000..d05198786 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/runs/__tests__/registration.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, vi } from "vitest"; +import type { CollectionConfig, Config, PayloadRequest } from "payload"; + +import { translatorPlugin } from "../../../../plugin.js"; +import type { TranslatorPluginConfig } from "../../../../plugin.js"; +import { createSyncRunner } from "../../task-runner/sync-runner/index.js"; +import type { TranslationProvider } from "../../../../core/domain/translation-providers/index.js"; +import { documentTranslation } from "../../../../composition/modules/index.js"; +import { TranslatorConfigError } from "../../../../core/errors/index.js"; +import { DEFAULT_RUNS_SLUG, isRunRecordCollection } from "../RunRecord.collection.js"; + +const provider: TranslationProvider = { translate: vi.fn().mockResolvedValue({}) }; + +const postsFixture = (): CollectionConfig => ({ + slug: "posts", + fields: [{ name: "title", type: "text", localized: true }], +}); + +const boot = async ( + alongside: CollectionConfig[] = [], + runs?: TranslatorPluginConfig["runs"] +): Promise => { + const posts = postsFixture(); + const hostCollections = [posts, ...alongside]; + const options: TranslatorPluginConfig = { + collections: [posts], + translationProvider: provider, + runner: createSyncRunner(), + access: { check: () => true }, + modules: [documentTranslation()], + components: [], + runs, + }; + + return await translatorPlugin(options)({ + collections: hostCollections, + localization: { defaultLocale: "en", locales: ["en", "de"] }, + } as unknown as Config); +}; + +const anyAsk = { req: {} as unknown as PayloadRequest }; + +const theRunRecord = (config: Config): CollectionConfig => { + const found = (config.collections ?? []).find((collection) => isRunRecordCollection(collection)); + if (!found) throw new Error("the plugin registered no run record collection"); + return found; +}; + +describe("registering the run record", () => { + it("registers the collection under its default slug", async () => { + const config = await boot(); + + expect(theRunRecord(config).slug).toBe(DEFAULT_RUNS_SLUG); + }); + + it("honours the configured slug", async () => { + const config = await boot([], { slug: "our-runs" }); + + expect(theRunRecord(config).slug).toBe("our-runs"); + expect((config.collections ?? []).map((c) => c.slug)).not.toContain(DEFAULT_RUNS_SLUG); + }); + + it("leaves the record closed to everyone and out of the admin", async () => { + const record = theRunRecord(await boot()); + + expect(record.admin?.hidden).toBe(true); + expect(record.access?.read?.(anyAsk)).toBe(false); + expect(record.access?.create?.(anyAsk)).toBe(false); + expect(record.access?.update?.(anyAsk)).toBe(false); + expect(record.access?.delete?.(anyAsk)).toBe(false); + }); + + it("refuses to boot when the slug collides with a host collection", async () => { + const collider = (): CollectionConfig[] => [{ slug: DEFAULT_RUNS_SLUG, fields: [] }]; + + await expect(boot(collider())).rejects.toThrow(TranslatorConfigError); + await expect(boot(collider())).rejects.toThrow(/runs\.slug/u); + }); + + it("registers one record however often the plugin is applied", async () => { + const posts = postsFixture(); + const once = await translatorPlugin({ + collections: [posts], + translationProvider: provider, + runner: createSyncRunner(), + access: { check: () => true }, + modules: [documentTranslation()], + components: [], + })({ + collections: [posts], + localization: { defaultLocale: "en", locales: ["en", "de"] }, + } as unknown as Config); + const twice = await translatorPlugin({ + collections: [posts], + translationProvider: provider, + runner: createSyncRunner(), + access: { check: () => true }, + modules: [documentTranslation()], + components: [], + })(once); + + const records = (twice.collections ?? []).filter((collection) => + isRunRecordCollection(collection) + ); + expect(records).toHaveLength(1); + }); +}); + +describe("the cascade that follows a deleted document", () => { + it("hooks every translatable collection", async () => { + const config = await boot(); + + const hooked = (config.collections ?? []).find((c) => c.slug === "posts"); + expect(hooked?.hooks?.afterDelete ?? []).toHaveLength(1); + }); + + it("never hooks the record itself, so the cascade cannot recurse", async () => { + const record = theRunRecord(await boot()); + + expect(record.hooks?.afterDelete ?? []).toHaveLength(0); + }); + + it("hooks no collection the plugin does not translate", async () => { + const config = await boot([{ slug: "media", fields: [] }]); + + const other = (config.collections ?? []).find((c) => c.slug === "media"); + expect(other?.hooks?.afterDelete ?? []).toHaveLength(0); + }); +}); diff --git a/packages/payload-plugin-translator/src/server/modules/runs/__tests__/runAssignments.test.ts b/packages/payload-plugin-translator/src/server/modules/runs/__tests__/runAssignments.test.ts new file mode 100644 index 000000000..a19dff33b --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/runs/__tests__/runAssignments.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { assignmentsFromRows } from "../runAssignments.js"; + +import { runRow } from "./fixtures/runRecordStore.js"; + +describe("what each run among a set of rows is about", () => { + it("answers once per run, however many locales it covers", () => { + const placed = assignmentsFromRows([ + runRow({ handle: "run-1", targetLng: "de" }), + runRow({ handle: "run-1", targetLng: "fr" }), + runRow({ handle: "run-2", targetLng: "de" }), + ]); + + expect(placed.map((entry) => entry.handle)).toEqual(["run-1", "run-2"]); + }); + + it("names the document the run belongs to", () => { + const [placed] = assignmentsFromRows([runRow({ handle: "run-1", collectionId: "doc-9" })]); + + expect(placed).toMatchObject({ collectionSlug: "posts", collectionId: "doc-9" }); + }); + + it("names whoever asked for the run", () => { + const [placed] = assignmentsFromRows([ + runRow({ requesterId: "anna", requesterCollection: "users" }), + ]); + + expect(placed.startedBy).toEqual({ id: "anna", collection: "users" }); + }); + + it("names nobody when half the requester is missing, rather than half a user", () => { + const [onlyAnId] = assignmentsFromRows([runRow({ requesterId: "anna" })]); + const [onlyACollection] = assignmentsFromRows([runRow({ requesterCollection: "users" })]); + + expect(onlyAnId.startedBy).toBeNull(); + expect(onlyACollection.startedBy).toBeNull(); + }); + + it("invents nothing from an empty set", () => { + expect(assignmentsFromRows([])).toEqual([]); + }); +}); diff --git a/packages/payload-plugin-translator/src/server/modules/runs/__tests__/runStatus.test.ts b/packages/payload-plugin-translator/src/server/modules/runs/__tests__/runStatus.test.ts new file mode 100644 index 000000000..462b7d74a --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/runs/__tests__/runStatus.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { lastTouched, reportedRuns, statusOf } from "../runStatus.js"; + +import { runRow } from "./fixtures/runRecordStore.js"; + +const SOME_MOMENT = "2026-10-27T10:01:00.000Z"; +const A_LATER_MOMENT = "2026-10-27T10:02:00.000Z"; + +describe("what a row of the run record means", () => { + it("is pending while nothing has started it", () => { + expect(statusOf(runRow())).toBe("pending"); + }); + + it("is running once the plugin's handler has been called", () => { + expect(statusOf(runRow({ startedAt: SOME_MOMENT }))).toBe("running"); + }); + + it("is completed when the locale was delivered", () => { + expect(statusOf(runRow({ outcome: "delivered", settledAt: SOME_MOMENT }))).toBe("completed"); + }); + + it("is failed when the run gave up on the locale", () => { + expect(statusOf(runRow({ outcome: "failed", settledAt: SOME_MOMENT }))).toBe("failed"); + }); + + it("has no word for a cancelled locale, and says so rather than guessing", () => { + expect(statusOf(runRow({ outcome: "cancelled", settledAt: SOME_MOMENT }))).toBeNull(); + }); + + it("reads the outcome over the stamp, so a settled run is never reported as running", () => { + const row = runRow({ startedAt: SOME_MOMENT, settledAt: SOME_MOMENT, outcome: "delivered" }); + + expect(statusOf(row)).toBe("completed"); + }); +}); + +describe("the rows a panel can speak about", () => { + it("drops the ones with no word and keeps the rest with their status", () => { + const kept = reportedRuns([ + runRow({ handle: "a" }), + runRow({ handle: "b", outcome: "cancelled", settledAt: SOME_MOMENT }), + runRow({ handle: "c", startedAt: SOME_MOMENT }), + ]); + + expect(kept.map(({ row, status }) => [row.handle, status])).toEqual([ + ["a", "pending"], + ["c", "running"], + ]); + }); +}); + +describe("when a row was last touched", () => { + it("answers with the settlement once there is one, not with the start it came after", () => { + expect( + lastTouched( + runRow({ startedAt: SOME_MOMENT, settledAt: A_LATER_MOMENT, outcome: "delivered" }) + ), + "the panel sorts on this, and a settled run that reports its start reads as older than it is" + ).toBe(A_LATER_MOMENT); + }); + + it("answers with the start while the locale is still running", () => { + expect(lastTouched(runRow({ startedAt: SOME_MOMENT }))).toBe(SOME_MOMENT); + }); + + it("falls back to when it was queued, which every row has", () => { + const queued = runRow(); + + expect(lastTouched(queued)).toBe(queued.queuedAt); + }); +}); diff --git a/packages/payload-plugin-translator/src/server/modules/runs/__tests__/store.test.ts b/packages/payload-plugin-translator/src/server/modules/runs/__tests__/store.test.ts new file mode 100644 index 000000000..34c31e059 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/runs/__tests__/store.test.ts @@ -0,0 +1,414 @@ +import { describe, it, expect, vi } from "vitest"; +import type { Payload } from "payload"; + +import { PayloadRunRecordStore } from "../RunRecord.store.js"; +import type { RunIdentity, RunRecord } from "../RunRecord.types.js"; + +const SLUG = "translator-runs"; + +const identity = (over: Partial = {}): RunIdentity => ({ + handle: "job-1", + collectionSlug: "posts" as RunRecord["collectionSlug"], + collectionId: "d1", + sourceLng: "en", + targetLng: "de", + filters: null, + publishOnTranslation: false, + requesterId: null, + requesterCollection: null, + queuedAt: "2026-10-26T10:00:00.000Z", + ...over, +}); + +const record = (over: Partial = {}): RunRecord => ({ + ...identity(), + startedAt: null, + settledAt: null, + outcome: null, + error: null, + attempts: null, + ...over, +}); + +const theKey = { handle: "job-1", targetLng: "de" }; +const KEY_WHERE = { and: [{ handle: { equals: "job-1" } }, { targetLng: { equals: "de" } }] }; + +const payloadWith = (over: Partial>) => { + const spies = { + find: vi.fn().mockResolvedValue({ docs: [] }), + create: vi.fn().mockResolvedValue({ id: "row-1" }), + update: vi.fn().mockResolvedValue({ docs: [{ id: "row-1" }] }), + delete: vi.fn().mockResolvedValue({ docs: [] }), + ...over, + }; + return { payload: spies as unknown as Payload, spies }; +}; + +describe("the run record store", () => { + describe("recording that a locale is queued", () => { + it("creates a row when the key names nothing yet, with no progress on it", async () => { + const { payload, spies } = payloadWith({}); + + await new PayloadRunRecordStore(payload, SLUG).recordQueued(identity()); + + expect(spies.create).toHaveBeenCalledTimes(1); + expect(spies.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + handle: "job-1", + startedAt: null, + settledAt: null, + outcome: null, + error: null, + attempts: null, + }), + }) + ); + }); + + it("leaves a key already recorded exactly as it stands", async () => { + const { payload, spies } = payloadWith({ + find: vi.fn().mockResolvedValue({ + docs: [ + { + id: "row-9", + collectionSlug: "posts", + collectionId: "d1", + startedAt: "2026-10-26T10:01:00.000Z", + }, + ], + }), + }); + + await new PayloadRunRecordStore(payload, SLUG).recordQueued(identity()); + + expect(spies.create, "an existing row is not replaced").not.toHaveBeenCalled(); + expect(spies.update, "nor patched back to empty").not.toHaveBeenCalled(); + }); + + it("replaces a row whose key names a different document, because the handle came round again", async () => { + const { payload, spies } = payloadWith({ + find: vi.fn().mockResolvedValue({ + docs: [{ id: "row-9", collectionSlug: "posts", collectionId: "an-older-document" }], + }), + }); + + await new PayloadRunRecordStore(payload, SLUG).recordQueued(identity()); + + expect( + spies.update, + "a SQL store hands a deleted job's id back out, and an adopted run is named by it" + ).toHaveBeenCalledWith( + expect.objectContaining({ + id: "row-9", + data: expect.objectContaining({ collectionId: "d1", startedAt: null }), + }) + ); + }); + + it("names a row by handle and target locale together, never by handle alone", async () => { + const { payload, spies } = payloadWith({}); + + await new PayloadRunRecordStore(payload, SLUG).recordQueued(identity()); + + expect(spies.find).toHaveBeenCalledWith(expect.objectContaining({ where: KEY_WHERE })); + }); + + it("lets the winner's row stand when another writer took the key first", async () => { + const find = vi + .fn() + .mockResolvedValueOnce({ docs: [] }) + .mockResolvedValueOnce({ docs: [{ id: "theirs" }] }); + const { payload, spies } = payloadWith({ + find, + create: vi.fn().mockRejectedValue(new Error("duplicate key")), + }); + + await new PayloadRunRecordStore(payload, SLUG).recordQueued(identity()); + + expect( + spies.update, + "the winner wrote the same identity we would have" + ).not.toHaveBeenCalled(); + }); + + it("gives up loudly when the create failed for a reason that is not a lost race", async () => { + const boom = new Error("disk is full"); + const { payload } = payloadWith({ create: vi.fn().mockRejectedValue(boom) }); + + await expect(new PayloadRunRecordStore(payload, SLUG).recordQueued(identity())).rejects.toBe( + boom + ); + }); + + it("joins the caller's transaction, so a rolled-back save takes the row with it", async () => { + const { payload, spies } = payloadWith({}); + + await new PayloadRunRecordStore(payload, SLUG, { transactionID: "tx-1" }).recordQueued( + identity() + ); + + expect(spies.create).toHaveBeenCalledWith( + expect.objectContaining({ req: { transactionID: "tx-1" } }) + ); + }); + }); + + describe("stamping and settling", () => { + it("stamps the start against the key alone, creating nothing", async () => { + const { payload, spies } = payloadWith({}); + + await new PayloadRunRecordStore(payload, SLUG).recordStarted(theKey, { + startedAt: "2026-10-26T10:01:00.000Z", + attempts: 1, + }); + + expect(spies.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: KEY_WHERE, + data: { startedAt: "2026-10-26T10:01:00.000Z", attempts: 1 }, + }) + ); + expect( + spies.create, + "a locale nobody queued gets no row from a stamp" + ).not.toHaveBeenCalled(); + }); + + it("settles against the key alone, so the run's other locales are untouched", async () => { + const { payload, spies } = payloadWith({}); + + await new PayloadRunRecordStore(payload, SLUG).recordSettled(theKey, { + settledAt: "2026-10-26T10:02:00.000Z", + outcome: "failed", + error: "it broke", + }); + + expect(spies.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: KEY_WHERE, + data: { settledAt: "2026-10-26T10:02:00.000Z", outcome: "failed", error: "it broke" }, + }) + ); + }); + + it("settles again over a row already settled, because the later report is what happened", async () => { + const { payload, spies } = payloadWith({ + find: vi.fn().mockResolvedValue({ docs: [{ id: "row-1", outcome: "cancelled" }] }), + }); + + await new PayloadRunRecordStore(payload, SLUG).recordSettled(theKey, { + settledAt: "2026-10-26T10:03:00.000Z", + outcome: "delivered", + error: null, + }); + + expect(spies.update).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ outcome: "delivered" }) }) + ); + }); + + it("joins the caller's transaction when settling", async () => { + const { payload, spies } = payloadWith({}); + + await new PayloadRunRecordStore(payload, SLUG, { transactionID: "tx-2" }).recordSettled( + theKey, + { settledAt: "2026-10-26T10:02:00.000Z", outcome: "delivered", error: null } + ); + + expect(spies.update).toHaveBeenCalledWith( + expect.objectContaining({ req: { transactionID: "tx-2" } }) + ); + }); + }); + + describe("reading a document's rows", () => { + it("asks about a document by collection and id together", async () => { + const { payload, spies } = payloadWith({}); + + await new PayloadRunRecordStore(payload, SLUG).findByDocument({ + collectionSlug: "posts" as RunRecord["collectionSlug"], + collectionId: "d1", + }); + + expect(spies.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + and: [{ collectionSlug: { equals: "posts" } }, { collectionId: { equals: "d1" } }], + }, + }) + ); + }); + + it("reads a row back as the record that was stored", async () => { + const stored = record({ outcome: "delivered", settledAt: "2026-10-26T10:05:00.000Z" }); + const { payload } = payloadWith({ + find: vi.fn().mockResolvedValue({ docs: [{ id: "row-1", ...stored }] }), + }); + + const [found] = await new PayloadRunRecordStore(payload, SLUG).findByDocument({ + collectionSlug: "posts" as RunRecord["collectionSlug"], + collectionId: "d1", + }); + + expect(found).toMatchObject({ handle: "job-1", targetLng: "de", outcome: "delivered" }); + }); + }); + + describe("reading what a run or a collection holds", () => { + it("asks about several handles in one question", async () => { + const { payload, spies } = payloadWith({}); + + await new PayloadRunRecordStore(payload, SLUG).findByHandles(["a", "b"]); + + expect(spies.find).toHaveBeenCalledWith( + expect.objectContaining({ where: { handle: { in: ["a", "b"] } } }) + ); + }); + + it("asks nothing at all when no handle was named", async () => { + const { payload, spies } = payloadWith({}); + + const found = await new PayloadRunRecordStore(payload, SLUG).findByHandles([]); + + expect(found).toEqual([]); + expect(spies.find).not.toHaveBeenCalled(); + }); + + it("answers with no row for a handle nothing holds, rather than an invented one", async () => { + const { payload } = payloadWith({ + find: vi + .fn() + .mockResolvedValue({ docs: [{ id: "row-1", ...record({ handle: "known" }) }] }), + }); + + const found = await new PayloadRunRecordStore(payload, SLUG).findByHandles([ + "known", + "a-stranger", + ]); + + expect(found.map((row) => row.handle)).toEqual(["known"]); + }); + + it("asks for a collection's work in flight by the absence of a settlement", async () => { + const { payload, spies } = payloadWith({}); + + await new PayloadRunRecordStore(payload, SLUG).findLiveInCollection( + "posts" as RunRecord["collectionSlug"] + ); + + expect(spies.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + and: [{ collectionSlug: { equals: "posts" } }, { settledAt: { exists: false } }], + }, + }) + ); + }); + + it("asks about named documents by both halves of the key and the absence of a settlement", async () => { + const { payload, spies } = payloadWith({}); + + await new PayloadRunRecordStore(payload, SLUG).findLiveForDocuments( + "posts" as RunRecord["collectionSlug"], + ["d1", "d2"] + ); + + expect(spies.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + and: [ + { collectionSlug: { equals: "posts" } }, + { collectionId: { in: ["d1", "d2"] } }, + { settledAt: { exists: false } }, + ], + }, + }) + ); + }); + + it("asks nothing at all when no document was named", async () => { + const { payload, spies } = payloadWith({}); + + const found = await new PayloadRunRecordStore(payload, SLUG).findLiveForDocuments( + "posts" as RunRecord["collectionSlug"], + [] + ); + + expect(found).toEqual([]); + expect(spies.find).not.toHaveBeenCalled(); + }); + + it("asks for a collection's recent failures by outcome and moment", async () => { + const { payload, spies } = payloadWith({}); + + await new PayloadRunRecordStore(payload, SLUG).findRecentlyFailedInCollection( + "posts" as RunRecord["collectionSlug"], + new Date("2026-10-20T00:00:00.000Z") + ); + + expect(spies.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + and: [ + { collectionSlug: { equals: "posts" } }, + { outcome: { equals: "failed" } }, + { settledAt: { greater_than: "2026-10-20T00:00:00.000Z" } }, + ], + }, + }) + ); + }); + + it("bounds every read, because nothing prunes the rows", async () => { + const find = vi.fn().mockResolvedValue({ docs: [] }); + const { payload } = payloadWith({ find }); + const store = new PayloadRunRecordStore(payload, SLUG); + + await store.findLiveInCollection("posts" as RunRecord["collectionSlug"]); + await store.findByDocument({ + collectionSlug: "posts" as RunRecord["collectionSlug"], + collectionId: "d1", + }); + + for (const [asked] of find.mock.calls) { + expect(asked.limit, "a read with no limit grows with the table").toBeGreaterThan(0); + expect(asked.pagination, "and pagination off with no limit is unbounded").not.toBe(false); + } + }); + + it("names the order every bounded read keeps, so the rows it drops are the oldest", async () => { + const find = vi.fn().mockResolvedValue({ docs: [] }); + const { payload } = payloadWith({ find }); + const store = new PayloadRunRecordStore(payload, SLUG); + + await store.findLiveInCollection("posts" as RunRecord["collectionSlug"]); + await store.findByDocument({ + collectionSlug: "posts" as RunRecord["collectionSlug"], + collectionId: "d1", + }); + + for (const [asked] of find.mock.calls) { + expect( + asked.sort, + "a limit with no order keeps an arbitrary page, so a busy collection hides its newest runs" + ).toBe("-queuedAt"); + } + }); + }); + + describe("deleting a document's rows", () => { + it("stays outside the caller's transaction, so a failed cleanup cannot undo their delete", async () => { + const { payload, spies } = payloadWith({}); + + await new PayloadRunRecordStore(payload, SLUG, { transactionID: "tx-1" }).deleteByDocument({ + collectionSlug: "posts" as RunRecord["collectionSlug"], + collectionId: "d1", + }); + + expect(spies.delete).toHaveBeenCalledWith( + expect.not.objectContaining({ req: expect.anything() }) + ); + }); + }); +}); diff --git a/packages/payload-plugin-translator/src/server/modules/runs/__tests__/storedFilters.test.ts b/packages/payload-plugin-translator/src/server/modules/runs/__tests__/storedFilters.test.ts new file mode 100644 index 000000000..ffc925858 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/runs/__tests__/storedFilters.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import { filtersFromStore, storedFilters } from "../storedFilters.js"; +import type { TranslationFilter } from "../../../../core/domain/selection/index.js"; + +const aFilter = [{ kind: "paths", paths: ["title"] }] as unknown as TranslationFilter[]; + +describe("what a locale's filters look like in the row", () => { + it("stores nothing when the ask carried no filters", () => { + expect(storedFilters(undefined)).toBeNull(); + }); + + it("stores nothing for an empty list, which asks for the whole document just the same", () => { + expect(storedFilters([])).toBeNull(); + }); + + it("stores the filters as they were asked, readable back as themselves", () => { + const stored = storedFilters(aFilter); + + expect(stored).not.toBeNull(); + expect(JSON.parse(stored as string)).toEqual(aFilter); + }); +}); + +describe("what a stored filters value means, read back", () => { + const readable = { kind: "rule", name: "stale" }; + + it("keeps the entries that read, and drops only the one that does not", () => { + expect( + filtersFromStore(JSON.stringify([readable, { nonsense: true }])), + "dropping the whole list would translate nothing for a locale that asked for something" + ).toEqual([readable]); + }); + + it("asks for the whole document when the stored text is not JSON at all", () => { + expect(filtersFromStore("}{ not json")).toEqual([]); + }); + + it("asks for the whole document when the stored JSON is not a list", () => { + expect(filtersFromStore(JSON.stringify(readable))).toEqual([]); + }); + + it("asks for the whole document when nothing was stored", () => { + expect(filtersFromStore(null)).toEqual([]); + }); +}); diff --git a/packages/payload-plugin-translator/src/server/modules/runs/index.ts b/packages/payload-plugin-translator/src/server/modules/runs/index.ts new file mode 100644 index 000000000..4813ea3b1 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/runs/index.ts @@ -0,0 +1,8 @@ +export { configureRunRecord } from "./RunRecord.wiring.js"; +export type { RunRecordStoreFactory } from "./RunRecord.store.js"; +export type { RunRecord } from "./RunRecord.types.js"; +export { assignmentsFromRows } from "./runAssignments.js"; +export { lastTouched, reportedRuns } from "./runStatus.js"; +export type { ReportedRun } from "./runStatus.js"; +export { filtersFromStore, storedFilters } from "./storedFilters.js"; +export { RunRecordMissing } from "./RunRecordMissing.js"; diff --git a/packages/payload-plugin-translator/src/server/modules/runs/runAssignments.ts b/packages/payload-plugin-translator/src/server/modules/runs/runAssignments.ts new file mode 100644 index 000000000..65c59d99c --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/runs/runAssignments.ts @@ -0,0 +1,25 @@ +import type { JobAssignment } from "../task-runner/types.js"; + +import type { RunRecord } from "./RunRecord.types.js"; + +function requesterOf(row: RunRecord): JobAssignment["startedBy"] { + if (row.requesterId === null || row.requesterCollection === null) return null; + return { id: row.requesterId, collection: row.requesterCollection }; +} + +/** + * One entry per handle: a run's rows differ only by locale, so whichever row lands last answers for + * the run. + */ +export function assignmentsFromRows(rows: readonly RunRecord[]): JobAssignment[] { + const byHandle = new Map(); + for (const row of rows) { + byHandle.set(row.handle, { + handle: row.handle, + collectionSlug: row.collectionSlug, + collectionId: row.collectionId, + startedBy: requesterOf(row), + }); + } + return [...byHandle.values()]; +} diff --git a/packages/payload-plugin-translator/src/server/modules/runs/runStatus.ts b/packages/payload-plugin-translator/src/server/modules/runs/runStatus.ts new file mode 100644 index 000000000..c2e7c59a8 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/runs/runStatus.ts @@ -0,0 +1,31 @@ +import type { TaskStatus } from "../task-runner/types.js"; + +import type { RunRecord } from "./RunRecord.types.js"; + +export type ReportedRun = { + row: RunRecord; + status: TaskStatus; +}; + +/** + * What one row means in the vocabulary the panels speak. A cancelled row maps to `null` rather than + * a word of its own — the admin has no word for it, and `reportedRuns` drops what maps to `null`. + */ +export function statusOf(record: RunRecord): TaskStatus | null { + if (record.outcome === "cancelled") return null; + if (record.outcome === "failed") return "failed"; + if (record.outcome === "delivered") return "completed"; + return record.startedAt === null ? "pending" : "running"; +} + +export function reportedRuns(rows: readonly RunRecord[]): ReportedRun[] { + const reported: ReportedRun[] = []; + for (const row of rows) { + const status = statusOf(row); + if (status !== null) reported.push({ row, status }); + } + return reported; +} + +export const lastTouched = (record: RunRecord): string => + record.settledAt ?? record.startedAt ?? record.queuedAt; diff --git a/packages/payload-plugin-translator/src/server/modules/runs/slugGuard.ts b/packages/payload-plugin-translator/src/server/modules/runs/slugGuard.ts new file mode 100644 index 000000000..42ced6119 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/runs/slugGuard.ts @@ -0,0 +1,18 @@ +import { TranslatorConfigError } from "../../../core/errors/index.js"; + +/** + * Refuse a slug the consumer's own collections already use — one table written by both would corrupt + * both. Exact string match only: `@payloadcms/drizzle` snake-cases slugs and `dbName` can override + * them, so case and separator variants are not caught. + */ +export function assertRunRecordSlugFree( + slug: string, + collections: ReadonlyArray<{ slug: string }> +): void { + if (collections.some((collection) => collection.slug === slug)) { + throw new TranslatorConfigError( + `[payload-plugin-translator] the run record slug "${slug}" collides with an existing collection. ` + + "Set a distinct slug via the plugin's runs.slug option." + ); + } +} diff --git a/packages/payload-plugin-translator/src/server/modules/runs/storedFilters.ts b/packages/payload-plugin-translator/src/server/modules/runs/storedFilters.ts new file mode 100644 index 000000000..e49df4e3a --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/runs/storedFilters.ts @@ -0,0 +1,31 @@ +import { TranslationFilterSchema } from "../../../core/domain/selection/index.js"; +import type { TranslationFilter } from "../../../core/domain/selection/index.js"; + +/** + * No filters means the whole document, so an empty list stores as `null` rather than `"[]"` — the + * two must not be told apart downstream. + */ +export const storedFilters = (filters?: readonly TranslationFilter[]): string | null => + filters && filters.length > 0 ? JSON.stringify(filters) : null; + +function readJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +/** + * An entry the parser rejects is dropped. A value that is not a list — or one whose every entry is + * rejected — reads as no filters at all, which means the whole document. + */ +export function filtersFromStore(stored: string | null): TranslationFilter[] { + if (stored === null) return []; + const parsed = readJson(stored); + if (!Array.isArray(parsed)) return []; + return parsed.flatMap((entry) => { + const filter = TranslationFilterSchema.safeParse(entry); + return filter.success ? [filter.data] : []; + }); +} diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/RunnerCapabilities.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/RunnerCapabilities.ts new file mode 100644 index 000000000..741f4f929 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/RunnerCapabilities.ts @@ -0,0 +1,15 @@ +/** + * What a runner can do beyond accepting work. + * + * Declared by the provider rather than read off a runner: routes are registered while the config is + * being built, before any `payload` exists. A capability left false means the route is not + * registered and the admin draws no control for it, so no button answers 404. + * + * `assertTaskRunnerContract` asserts that a declared capability is really implemented. + * + * @since 1.0.0 + */ +export type RunnerCapabilities = { + stop: boolean; + run: boolean; +}; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/TaskRunner.interface.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/TaskRunner.interface.ts index 790b002dd..28ec9fe2e 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/TaskRunner.interface.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/TaskRunner.interface.ts @@ -1,16 +1,10 @@ -import type { CollectionSlug } from "payload"; - -import type { EnqueueAssignment, JobAssignment, JobHandle, Task, RunResult } from "./types.js"; +import type { EnqueueAssignment, RunResult } from "./types.js"; import type { RequestScope } from "../../shared/payload/RequestScope.shapes.js"; import type { AuthorisedHandle, AuthorisedWork } from "../../shared/access/authorised.js"; /** * Interface for task execution backends. * - * Implementations handle queuing, cancellation, status tracking, - * and execution of translation tasks. All business logic - * (like cancelling existing tasks before enqueue) is encapsulated - * within the implementation. * * **Obligations every implementation holds, whatever it queues onto.** `assertTaskRunnerContract`, * published from `@focus-reactive/payload-plugin-translator/testing`, asserts them; a new runner @@ -27,77 +21,35 @@ import type { AuthorisedHandle, AuthorisedWork } from "../../shared/access/autho * And a store may reject a value it could never have produced: on SQL the job id is an integer, so * a non-numeric handle fails inside the query rather than finding nothing. `DELETE /translate/cancel` * passes such a value straight through and answers with a server error. + * @since 1.0.0 — the port narrowed; see docs/DEPRECATIONS.md#the-runner-only-accepts-work. */ export interface TaskRunner { /** - * Queue translation tasks for execution. + * Queues the work and answers one assignment per requested locale. * * `scope` joins the reads and writes this makes to the caller's transaction; omit it outside one — * an HTTP route — and each operation opens its own. * - * Answering with nothing still queues and still satisfies this contract — deprecated, - * docs/DEPRECATIONS.md#enqueue-void-return. + * The plugin has already decided that each document named here is free to take new work, so a + * runner never looks for a run of its own to merge into. * * @since 0.16.0 the return type widened. */ - enqueue(tasks: AuthorisedWork[], scope?: RequestScope): Promise; + enqueue(tasks: AuthorisedWork[], scope?: RequestScope): Promise; /** * Stop the work these handles stand for, as far as this runner can. * - * Best effort: a runner that translates inline inside `enqueue` has nothing left to stop. + * Best effort: a runner that translates inline inside `enqueue` has nothing left to stop, and says + * so by declaring `stop: false` rather than by implementing a no-op. * Report `cancelled` for each locale the run still owed, and for none it had delivered. */ - cancel(taskIds: AuthorisedHandle[]): Promise; - - /** - * Execute a task immediately. - * Returns error status if task not found, already running, or completed. - */ - run(taskId: AuthorisedHandle): Promise; + stop?(handles: AuthorisedHandle[]): Promise; /** - * The document and recorded requester behind each handle; a handle this runner cannot place is - * omitted, never guessed. Omitting covers a handle this runner does not have — a lookup it cannot - * perform at all throws, because an empty answer reads as "nothing left to cancel". + * Process a queued job now rather than waiting for a schedule. * - * Required: the run and cancel routes have no other way to reach the document, and Payload's own - * rule is asked about a document, never about a handle. + * On a host with no cron this is the only thing that moves the queue. */ - assignmentsOf(handles: readonly JobHandle[]): Promise; - - /** - * @deprecated Pass `{ documentIds }` instead. Removed in the next major. - * See docs/DEPRECATIONS.md#find-by-collection-document-ids-array - */ - findByCollection( - collectionSlug: CollectionSlug, - documentIds: Array - ): Promise; - /** Find tasks for a collection, optionally narrowed by a {@link TaskFilter}. */ - findByCollection(collectionSlug: CollectionSlug, filter?: TaskFilter): Promise; + run?(handle: AuthorisedHandle): Promise; } - -/** - * How a {@link TaskRunner["findByCollection"]} call is narrowed. Each field says whether it reaches the - * database or is applied in memory over everything the database returned. - * - * @since 0.11.2 - */ -export type TaskFilter = { - /** Keep only tasks for these documents. Applied in memory — see `PayloadJobsTaskRunner.findByCollection`. */ - documentIds?: Array; - /** - * Drop tasks that have finished. Keeps running and failed ones — everything a re-enqueue can still - * supersede — so it is wider than the `pending` status. - */ - excludeCompleted?: boolean; - /** - * Runs still in flight are kept however old: the queue gives up only after the last retry, not on - * a failed attempt (`queues/errors/handleTaskError.js`). Reaches the database under the Payload - * Jobs runner. - * - * @since 0.17.0 - */ - excludeGivenUpBefore?: Date; -}; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/TaskRunnerProvider.interface.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/TaskRunnerProvider.interface.ts index 7bd15baa9..d73d10664 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/TaskRunnerProvider.interface.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/TaskRunnerProvider.interface.ts @@ -1,17 +1,20 @@ -import type { CollectionSlug, Config, Payload } from "payload"; +import type { Config, Payload } from "payload"; import type { TaskRunner } from "./TaskRunner.interface.js"; -import type { EnqueueAssignment, ID, RunAsk, TaskEvent } from "./types.js"; +import type { RunnerCapabilities } from "./RunnerCapabilities.js"; +import type { JobHandle, RunRef, TaskEvent } from "./types.js"; import type { RequestScope } from "../../shared/payload/RequestScope.shapes.js"; /** * Input for task handler callback */ -export type TaskHandlerInput = RunAsk & { - collection: CollectionSlug; - collectionId: ID; - sourceLng: string; +/** + * What a runner hands back about one piece of work it has tried. + * + * @since 1.0.0 + */ +export type TaskHandlerInput = { + handle: JobHandle; targetLng: string; - publishOnTranslation: boolean; /** Zero on the first attempt, and on any runner that does not retry. */ previousAttempts: number; }; @@ -30,22 +33,25 @@ export type TaskHandler = ( */ export type TaskRunnerContext = { handler: TaskHandler; - collections: CollectionSlug[]; /** * Tell the plugin what became of one assignment — the only road to the host; a runner that never * calls this leaves its host hearing nothing. * * - `queued` when the work is accepted, then at most one terminal event per assignment. * - Every `queued` is sent before `enqueue` resolves, every `cancelled` before `cancel` resolves. - * - The assignment carries the handle the enqueue answer gave for that locale. - * - One `queued` per assignment the enqueue answered, **including a locale an existing run already - * covers**; terminal events are counted per assignment identity. + * - The reference carries the handle the enqueue answer gave for that locale. + * - One `queued` per locale the enqueue answered; terminal events are counted per locale. * - `cancelled` only ever follows a `cancel`; a locale already reported `failed` is settled and is * not owed. * + * A failing `queued` report leaves the run unrecorded: let it out of `enqueue` rather than + * logging it — `assertTaskRunnerContract` asserts this. + * + * `scope` joins the write to the caller's transaction; pass the one `enqueue` or `cancel` was given. * @since 0.16.0 + * @since 1.0.0 */ - report: (payload: Payload, assignment: EnqueueAssignment, event: TaskEvent) => Promise; + report: (payload: Payload, run: RunRef, event: TaskEvent, scope?: RequestScope) => Promise; }; /** @@ -57,6 +63,7 @@ export type TaskRunnerContext = { */ export type TaskRunnerFactory = { create(payload: Payload): TaskRunner; + capabilities: RunnerCapabilities; }; /** @@ -69,6 +76,13 @@ export type TaskRunnerFactory = { * Future implementations: createInngestRunner, createQStashRunner */ export interface TaskRunnerProvider { + /** + * What `create` returns can do beyond accepting work — see {@link RunnerCapabilities}. + * + * @since 1.0.0 + */ + capabilities: RunnerCapabilities; + /** * Creates a TaskRunner instance for runtime operations (enqueue, cancel, find). * diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.conformance.types.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.conformance.types.ts index ea9ff88e6..fe6fed317 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.conformance.types.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/__tests__/TaskRunner.conformance.types.ts @@ -7,29 +7,21 @@ import type { TaskRunnerProvider } from "../../../../index.js"; * * It imports one name from the published barrel, which is all a third-party runner can reach. * - * Two mutations must break this file, one runner each: narrowing `enqueue` to require assignments - * breaks `silent`, and dropping `report` from `TaskRunnerContext` breaks `answering`. Neither - * obliges a runner to *call* `report` — `silent` never does and compiles, because no type can + * `acceptsWorkAlone` compiles without ever calling `report`: no type can require that a function be + * called, so the obligation to report is the conformance suite's to assert, not this file's. * require that a function be called. - * - * `cannotPlaceItsHandles` pins the other direction, which providing a member cannot: making - * `assignmentsOf` optional again. */ const configure = () => (config: Config) => config; -export const silent: TaskRunnerProvider = { - create: () => ({ - enqueue: async () => undefined, - cancel: async (): Promise => undefined, - run: async () => ({ success: false, error: "not_found" }) as const, - assignmentsOf: async () => [], - findByCollection: async () => [], - }), +export const acceptsWorkAlone: TaskRunnerProvider = { + capabilities: { stop: false, run: false }, + create: () => ({ enqueue: async () => [] }), configure, }; -export const answering: TaskRunnerProvider = { +export const reports: TaskRunnerProvider = { + capabilities: { stop: true, run: true }, create: (payload, context) => ({ enqueue: async (tasks) => { const assigned = tasks.map((task) => ({ @@ -39,25 +31,32 @@ export const answering: TaskRunnerProvider = { targetLng: task.targetLng, handle: "run-1", })); - for (const a of assigned) await context.report(payload, a, { state: "queued" }); + for (const a of assigned) { + await context.report( + payload, + { handle: a.handle, targetLng: a.targetLng }, + { + state: "queued", + work: { + collectionSlug: a.collectionSlug, + collectionId: a.collectionId, + sourceLng: a.sourceLng, + publishOnTranslation: false, + }, + } + ); + } return assigned; }, - cancel: async (): Promise => undefined, + stop: async (): Promise => undefined, run: async () => ({ success: false, error: "not_found" }) as const, - assignmentsOf: async () => [], - findByCollection: async () => [], }), configure, }; -/** A runner that cannot say which document a handle names is not one the routes can ask about. */ -export const cannotPlaceItsHandles: TaskRunnerProvider = { - // @ts-expect-error `assignmentsOf` is required; this omits it - create: () => ({ - enqueue: async () => undefined, - cancel: async (): Promise => undefined, - run: async () => ({ success: false, error: "not_found" }) as const, - findByCollection: async () => [], - }), +/** A runner that does not say what it can do cannot have its routes registered. */ +// @ts-expect-error `capabilities` is required; this omits it +export const saysNothingAboutItself: TaskRunnerProvider = { + create: () => ({ enqueue: async () => [] }), configure, }; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/index.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/index.ts index bef1ef184..15df150de 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/index.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/index.ts @@ -5,9 +5,18 @@ export type { TaskRunnerFactory, TaskRunnerContext, } from "./TaskRunnerProvider.interface.js"; -export type { EnqueueAssignment, JobAssignment, Task, TaskInput, TaskStatus } from "./types.js"; +export type { + EnqueueAssignment, + ID, + JobAssignment, + QueuedWork, + RunRef, + Task, + TaskInput, + TaskStatus, +} from "./types.js"; +export type { RunnerCapabilities } from "./RunnerCapabilities.js"; export { createPayloadJobsRunner } from "./payload-jobs-runner/index.js"; export type { PayloadJobsRunnerOptions } from "./payload-jobs-runner/index.js"; export { createSyncRunner } from "./sync-runner/index.js"; -export type { TaskFilter, TaskRunner } from "./TaskRunner.interface.js"; -export { toTaskFilter } from "./toTaskFilter.js"; +export type { TaskRunner } from "./TaskRunner.interface.js"; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsRunnerProvider.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsRunnerProvider.ts index f93d9f624..91b9c2c0c 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsRunnerProvider.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsRunnerProvider.ts @@ -1,31 +1,25 @@ import { TranslatorConfigError } from "../../../../core/errors/index.js"; -import type { Config, Payload, WorkflowConfig } from "payload"; +import type { CollectionSlug, Config, Payload, WorkflowConfig } from "payload"; import type { TaskRunner } from "../TaskRunner.interface.js"; import type { PayloadJobsRunnerOptions, PayloadJobsRunnerConfig, AutoRunConfig } from "./config.js"; -import type { StoredTaskInput, StoredWorkflowInput } from "./store/index.js"; +import type { StoredTaskInput } from "./store/index.js"; import { PayloadJobsTaskRunner } from "./PayloadJobsTaskRunner.js"; import { chain, contribute } from "../../../shared/payload/contribute.js"; import { - assignmentOf, - readCollectionRef, + pointerOf, TaskInputSchema, toPayloadFields, - withLegacyCollection, + withoutAHandle, WorkflowInputSchema, } from "./store/index.js"; -import type { TaskStatusByLocale } from "./store/index.js"; -import { - attemptsBefore, - deliveredLocales, - localesAsTheyStand, - owedIfGaveUp, - retryLimitOf, -} from "./model/index.js"; +import type { PayloadJob, StoredWorkflowInput, TaskStatusByLocale } from "./store/index.js"; +import { attemptsBefore, deliveredLocales, owedIfGaveUp, retryLimitOf } from "./model/index.js"; +import { adoptLegacyJob } from "./legacy/index.js"; +import type { AdoptionDeps } from "./legacy/index.js"; import type { TaskRunnerContext, TaskRunnerProvider } from "../TaskRunnerProvider.interface.js"; +import type { RunnerCapabilities } from "../RunnerCapabilities.js"; import type { TaskEvent } from "../types.js"; -import type { Requester } from "../../../shared/payload/RequestScope.shapes.js"; -import { asRequester } from "../../../shared/payload/RequestScope.shapes.js"; const defaultAutoRun: Required = { cron: "* * * * *", @@ -69,14 +63,40 @@ type TranslateLocaleTask = { input: StoredTaskInput; }; -export function requesterOf(input: { - requester_id?: string | number | null; - requester_collection?: string | null; -}): Requester | null { - return asRequester(input.requester_id, input.requester_collection); +const A_QUEUE_CAN_BE_REACHED: RunnerCapabilities = { stop: true, run: true }; + +/** + * Gives every job queued before the upgrade its pointer. Completed rows are skipped: their work is + * over, and Payload may already have deleted them. + */ +async function adoptQueuedBeforeTheUpgrade( + deps: AdoptionDeps, + names: { workflowName: string; taskName: string } +): Promise { + const { docs } = await deps.payload.find({ + collection: deps.jobsCollection as CollectionSlug, + depth: 0, + pagination: false, + where: { + and: [ + { + or: [ + { workflowSlug: { equals: names.workflowName } }, + { taskSlug: { equals: names.taskName } }, + ], + }, + { completedAt: { exists: false } }, + withoutAHandle(), + ], + }, + }); + + for (const job of docs as unknown as PayloadJob[]) await adoptLegacyJob(deps, job); } export class PayloadJobsRunnerProvider implements TaskRunnerProvider { + readonly capabilities = A_QUEUE_CAN_BE_REACHED; + private readonly config: PayloadJobsRunnerConfig; constructor(options?: PayloadJobsRunnerOptions) { @@ -105,86 +125,24 @@ export class PayloadJobsRunnerProvider implements TaskRunnerProvider { } configure(context: TaskRunnerContext): (config: Config) => Config { - const { taskName, workflowName, queueName, retries, autoRun } = this.config; - const retryLimit = retryLimitOf(retries); - const { handler, collections, report } = context; + const { queueName, autoRun } = this.config; const thrown = new WeakMap(); return (config) => { - const inputSchema = withLegacyCollection(toPayloadFields(TaskInputSchema), collections); - const workflowInputSchema = withLegacyCollection( - toPayloadFields(WorkflowInputSchema), - collections - ); - - const task = { - slug: taskName, - inputSchema, - retries, - handler: async (args: TranslateLocaleTask) => { - const { collectionSlug, collectionId } = readCollectionRef(args.input); - try { - await handler( - args.req.payload, - { - collection: collectionSlug, - collectionId, - sourceLng: args.input.source_lng, - targetLng: args.input.target_lng, - filters: args.input.filters, - publishOnTranslation: args.input.publish_on_translation ?? false, - previousAttempts: attemptsBefore( - args.job?.taskStatus?.[taskName]?.[args.input.target_lng] - ), - }, - { requester: requesterOf(args.input) } - ); - } catch (error) { - if (args.job) thrown.set(args.job, error); - throw error; - } - return { output: {} }; - }, - }; - - const workflow: WorkflowConfig = { - slug: workflowName, - inputSchema: workflowInputSchema, - retries, - ...(config.jobs?.enableConcurrencyControl - ? { - concurrency: { - key: ({ input }) => `${input.collection_slug}:${input.collection_id}`, - exclusive: true, - }, - } - : {}), - handler: async ({ job, req, tasks }) => { - const runLocale = tasks[taskName]; - const reportFor = async (locale: string, event: TaskEvent) => { - const assignment = assignmentOf(job, locale); - if (assignment) await report(req.payload, assignment, event); - }; - const deliveredBefore = deliveredLocales(job); - - for (const { target, input } of localesAsTheyStand(job)) { - try { - await runLocale(target, { input }); - if (!deliveredBefore.has(target)) await reportFor(target, { state: "delivered" }); - } catch (error) { - const owed = thrown.has(job) ? owedIfGaveUp(job, taskName, target, retryLimit) : []; - for (const dead of owed) { - await reportFor(dead.input.targetLng, { state: "failed", error: thrown.get(job) }); - } - throw error; - } - } - }, - }; - + // Read before `??=` creates the bag, so an absent `jobs` is not mistaken for one that opted in. + const exclusivePerRun = Boolean(config.jobs?.enableConcurrencyControl); const jobs = (config.jobs ??= {}); - jobs.tasks = contribute(jobs.tasks, [task], (registered) => registered.slug); - jobs.workflows = contribute(jobs.workflows, [workflow], (registered) => registered.slug); + + jobs.tasks = contribute( + jobs.tasks, + [this.translateOneLocale(context, thrown)], + (registered) => registered.slug + ); + jobs.workflows = contribute( + jobs.workflows, + [this.walkTheRunsLocales(context, thrown, exclusivePerRun)], + (registered) => registered.slug + ); if (autoRun) { jobs.autoRun = contribute( jobs.autoRun, @@ -193,18 +151,116 @@ export class PayloadJobsRunnerProvider implements TaskRunnerProvider { ); } - config.onInit = chain(config.onInit, async (payload) => { + config.onInit = chain(config.onInit, this.reclaimAndAdopt(context)); + + return config; + }; + } + + /** A throw is kept against the job so the workflow can read back which locale gave up. */ + private translateOneLocale(context: TaskRunnerContext, thrown: WeakMap) { + const { taskName, retries } = this.config; + + return { + slug: taskName, + inputSchema: toPayloadFields(TaskInputSchema), + retries, + handler: async (args: TranslateLocaleTask) => { try { - await new PayloadJobsTaskRunner(payload, this.config, context).reclaimStaleJobs(); - } catch (err) { - payload.logger?.error?.({ - err, - msg: "[translator] failed to reclaim stale translation jobs", + await context.handler(args.req.payload, { + handle: args.input.handle, + targetLng: args.input.target_lng, + previousAttempts: attemptsBefore( + args.job?.taskStatus?.[taskName]?.[args.input.target_lng] + ), }); + } catch (error) { + if (args.job) thrown.set(args.job, error); + throw error; } - }); + return { output: {} }; + }, + }; + } - return config; + private walkTheRunsLocales( + context: TaskRunnerContext, + thrown: WeakMap, + exclusivePerRun: boolean + ): WorkflowConfig { + const { taskName, workflowName, jobsCollection, retries } = this.config; + const retryLimit = retryLimitOf(retries); + const { report } = context; + + return { + slug: workflowName, + inputSchema: toPayloadFields(WorkflowInputSchema), + retries, + ...(exclusivePerRun + ? { + concurrency: { + key: ({ input }) => String(input.handle ?? ""), + exclusive: true, + }, + } + : {}), + handler: async ({ job, req, tasks }) => { + const runLocale = tasks[taskName]; + const pointer = + pointerOf(job as PayloadJob) ?? + (await adoptLegacyJob( + { payload: req.payload, jobsCollection, report }, + job as PayloadJob + )); + if (pointer === null) return; + + const reportFor = (targetLng: string, event: TaskEvent) => + report(req.payload, { handle: pointer.handle, targetLng }, event); + const deliveredBefore = deliveredLocales(job as PayloadJob); + + for (const target of pointer.locales) { + try { + await runLocale(target, { input: { handle: pointer.handle, target_lng: target } }); + if (!deliveredBefore.has(target)) await reportFor(target, { state: "delivered" }); + } catch (error) { + const owed = thrown.has(job) + ? owedIfGaveUp(job as PayloadJob, taskName, target, retryLimit, pointer.locales) + : []; + for (const dead of owed) { + await reportFor(dead, { state: "failed", error: thrown.get(job) }); + } + throw error; + } + } + }, + }; + } + + /** Both halves are best-effort: a host whose queue cannot be tidied still boots. */ + private reclaimAndAdopt(context: TaskRunnerContext): (payload: Payload) => Promise { + const { jobsCollection, workflowName, taskName } = this.config; + const { report } = context; + + return async (payload) => { + try { + await new PayloadJobsTaskRunner(payload, this.config, context).reclaimStaleJobs(); + } catch (err) { + payload.logger?.error?.({ + err, + msg: "[translator] failed to reclaim stale translation jobs", + }); + } + try { + await adoptQueuedBeforeTheUpgrade( + { payload, jobsCollection, report }, + { workflowName, taskName } + ); + } catch (err) { + payload.logger?.error?.({ + err, + msg: "[translator] failed to adopt translation jobs queued before the upgrade", + }); + } }; } } diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.ts index be92fcd1c..9e6e19dea 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.ts @@ -1,34 +1,15 @@ -import type { Payload, Where, CollectionSlug } from "payload"; +import type { Payload, Where } from "payload"; -import type { TaskFilter, TaskRunner } from "../TaskRunner.interface.js"; +import type { TaskRunner } from "../TaskRunner.interface.js"; import type { TaskRunnerContext } from "../TaskRunnerProvider.interface.js"; -import { toTaskFilter } from "../toTaskFilter.js"; -import type { - EnqueueAssignment, - JobAssignment, - JobHandle, - Task, - TaskInput, - RunResult, -} from "../types.js"; +import type { EnqueueAssignment, TaskInput, RunResult } from "../types.js"; import type { PayloadJobsRunnerConfig } from "./config.js"; -import type { PayloadJob, StoredRunTarget, StoredWorkflowInput } from "./store/index.js"; -import { runLocales, runTargets } from "./store/index.js"; -import { - assignmentOf, - handleOf, - normalizeJobLocales, - ofCollection, - ofDocuments, - readCollectionRef, - readRequester, -} from "./store/index.js"; -import { owedOnCancel, planEnqueue } from "./model/index.js"; -import type { RequestShape } from "./model/index.js"; +import type { PayloadJob, StoredWorkflowInput } from "./store/index.js"; +import { ofHandles, pointerOf } from "./store/index.js"; +import { owedOnCancel } from "./model/index.js"; import type { RequestScope } from "../../../shared/payload/RequestScope.shapes.js"; import { freshReq } from "../../../shared/payload/RequestScope.shapes.js"; -const APPEND_ATTEMPTS = 2; const ENQUEUE_CONCURRENCY = 10; type QueueWorkflow = (args: { @@ -39,16 +20,21 @@ type QueueWorkflow = (args: { req?: { transactionID?: string | number }; }) => Promise<{ id?: unknown } | undefined>; -const assign = (request: RequestShape, targetLngs: string[], handle: string): EnqueueAssignment[] => - targetLngs.map((targetLng) => ({ - collectionSlug: request.collectionSlug as CollectionSlug, - collectionId: request.collectionId, - sourceLng: request.sourceLng, - targetLng, - handle, - })); - -function requestShape(task: TaskInput, scope: RequestScope): RequestShape { +/** + * What a run stores once for all of its locales. Two requests that differ in any of it cannot share + * a run, which is why they are grouped by it before anything is queued — and none of it reaches the + * job row, which carries a pointer. + */ +type RunGroup = { + collectionSlug: string; + collectionId: string; + sourceLng: string; + publishOnTranslation: boolean; + requesterId: string | number | null; + requesterCollection: string | null; +}; + +function runGroupOf(task: TaskInput, scope: RequestScope): RunGroup { return { collectionSlug: task.collectionSlug, collectionId: String(task.collectionId), @@ -59,21 +45,8 @@ function requestShape(task: TaskInput, scope: RequestScope): RequestShape { }; } -function requestKey(task: TaskInput, scope: RequestScope): string { - return JSON.stringify(requestShape(task, scope)); -} - -const settledSince = (moment: Date): Where => ({ - or: [{ hasError: { not_equals: true } }, { updatedAt: { greater_than: moment.toISOString() } }], -}); - -function documentKey(collectionSlug: string, collectionId: string): string { - return `${collectionSlug}\u0000${collectionId}`; -} - -function storedTargets(group: TaskInput[], locales: string[]): StoredRunTarget[] { - const asked = new Map(group.map((task) => [task.targetLng, task])); - return locales.map((lng) => ({ lng, filters: [...(asked.get(lng)?.filters ?? [])] })); +function runGroupKey(task: TaskInput, scope: RequestScope): string { + return JSON.stringify(runGroupOf(task, scope)); } export class PayloadJobsTaskRunner implements TaskRunner { @@ -88,185 +61,116 @@ export class PayloadJobsTaskRunner implements TaskRunner { } async enqueue(tasks: TaskInput[], scope: RequestScope = {}): Promise { - const byRequest = new Map(); + const byRunGroup = new Map(); for (const task of tasks) { - const key = requestKey(task, scope); - const group = byRequest.get(key) ?? []; + const key = runGroupKey(task, scope); + const group = byRunGroup.get(key) ?? []; group.push(task); - byRequest.set(key, group); - } - - const live = await this.findRawJobs({ completedAt: { exists: false } }, scope); - const liveByDocument = new Map(); - for (const job of live) { - const { collectionSlug, collectionId } = readCollectionRef(job.input); - const key = documentKey(collectionSlug, collectionId); - liveByDocument.set(key, [...(liveByDocument.get(key) ?? []), job]); + byRunGroup.set(key, group); } - const exclusiveQueue = Boolean(this.payload.config.jobs?.enableConcurrencyControl); - const groups = [...byRequest.values()]; + const groups = [...byRunGroup.values()]; const assigned: EnqueueAssignment[] = []; for (let i = 0; i < groups.length; i += ENQUEUE_CONCURRENCY) { const served = await Promise.all( - groups - .slice(i, i + ENQUEUE_CONCURRENCY) - .map((group) => this.serve(group, liveByDocument, exclusiveQueue, scope)) + groups.slice(i, i + ENQUEUE_CONCURRENCY).map((group) => this.serve(group, scope)) ); for (const entries of served) assigned.push(...entries); } - for (const assignment of assigned) { - await this.context.report(this.payload, assignment, { state: "queued" }); - } return assigned; } - private async serve( - group: TaskInput[], - liveByDocument: Map, - exclusiveQueue: boolean, - scope: RequestScope - ): Promise { + /** + * One run per request group, named by this plugin rather than by the queue. + * + * The `queued` reports must land before the job is queued: a cron that picks the job up first + * finds no record, translates nothing, and leaves a row that is never settled — which blocks the + * document for good. + */ + private async serve(group: TaskInput[], scope: RequestScope): Promise { const [first] = group; - const request = requestShape(first, scope); - const plan = planEnqueue({ - live: liveByDocument.get(documentKey(request.collectionSlug, request.collectionId)) ?? [], - request, - requested: storedTargets( - group, - group.map((t) => t.targetLng) - ), - exclusiveQueue, - }); - - const undelivered = plan.host - ? await this.extendJob(plan.host, storedTargets(group, plan.append), first.waitUntil, scope) - : []; - const queue = [...plan.queue, ...undelivered]; - const delivered = plan.append.filter((locale) => !undelivered.includes(locale)); - - const onTheHost = plan.host ? assign(request, delivered, String(plan.host.id)) : []; - const onTheCovering = plan.coveredBy - ? assign(request, plan.covered, String(plan.coveredBy.id)) - : []; - if (queue.length === 0) return [...onTheHost, ...onTheCovering]; - - const handle = await this.queueWorkflow( - request, - storedTargets(group, queue), - first.waitUntil, - scope - ); - return handle === null - ? [...onTheHost, ...onTheCovering] - : [...onTheHost, ...onTheCovering, ...assign(request, queue, handle)]; - } - - private async extendJob( - job: PayloadJob, - appending: StoredRunTarget[], - waitUntil: Date | undefined, - scope: RequestScope - ): Promise { - const locales = appending.map((target) => target.lng); - let current = job; - let undelivered = locales; - for (let attempt = 0; attempt < APPEND_ATTEMPTS; attempt++) { - const listed = runTargets(current.input); - const already = new Set(listed.map((target) => target.lng)); - const missing = appending.filter((target) => !already.has(target.lng)); - const debounce = waitUntil && !current.processing ? waitUntil.toISOString() : undefined; - if (missing.length === 0 && !debounce) return []; - - await this.payload.db.updateOne({ - req: freshReq(scope), - collection: this.config.jobsCollection, - id: job.id, - data: { - input: { ...current.input, targets: [...listed, ...missing] }, - ...(debounce ? { waitUntil: debounce } : {}), + const request = runGroupOf(first, scope); + const locales = [...new Set(group.map((task) => task.targetLng))]; + const asked = new Map(group.map((task) => [task.targetLng, task])); + const handle = crypto.randomUUID(); + + for (const targetLng of locales) { + await this.context.report( + this.payload, + { handle, targetLng }, + { + state: "queued", + work: { + collectionSlug: first.collectionSlug, + collectionId: request.collectionId, + sourceLng: request.sourceLng, + filters: asked.get(targetLng)?.filters, + publishOnTranslation: request.publishOnTranslation, + }, }, - returning: false, - }); + scope + ); + } - const reread = await this.findJobById(String(job.id), scope); - if (!reread || reread.completedAt) return locales; - current = reread; + await this.queueWorkflow(handle, locales, first.waitUntil, scope); - const stored = new Set(runLocales(current.input)); - undelivered = locales.filter((locale) => !stored.has(locale)); - if (undelivered.length === 0) return []; - } - return undelivered; + return locales.map((targetLng) => ({ + collectionSlug: first.collectionSlug, + collectionId: request.collectionId, + sourceLng: request.sourceLng, + targetLng, + handle, + })); } private async queueWorkflow( - request: RequestShape, - targets: StoredRunTarget[], + handle: string, + locales: string[], waitUntil: Date | undefined, scope: RequestScope - ): Promise { - const input: StoredWorkflowInput = { - collection_slug: request.collectionSlug, - collection_id: request.collectionId, - source_lng: request.sourceLng, - targets, - publish_on_translation: request.publishOnTranslation, - requester_id: scope.requester?.userId ?? null, - requester_collection: scope.requester?.userCollection ?? null, - }; - + ): Promise { const queueJob = this.payload.jobs.queue as unknown as QueueWorkflow; - return handleOf( - await queueJob({ - workflow: this.config.workflowName, - queue: this.config.queueName, - waitUntil, - input, - req: freshReq(scope), - }) - ); - } - - async assignmentsOf(handles: readonly JobHandle[]): Promise { - if (handles.length === 0) return []; - - return (await this.findRawJobs({ id: { in: handles } })).map((job) => { - const { collectionSlug, collectionId } = readCollectionRef(job.input); - return { - handle: String(job.id), - collectionSlug, - collectionId, - startedBy: readRequester(job.input), - }; + await queueJob({ + workflow: this.config.workflowName, + queue: this.config.queueName, + waitUntil, + input: { handle, locales }, + req: freshReq(scope), }); } - async cancel(taskIds: string[]): Promise { - if (taskIds.length === 0) return; - - for (const job of await this.findRawJobs({ id: { in: taskIds } })) { - for (const task of owedOnCancel(job)) { - const assignment = assignmentOf(job, task.input.targetLng); - if (assignment) { - await this.context.report(this.payload, assignment, { state: "cancelled" }); - } + async stop(handles: string[]): Promise { + if (handles.length === 0) return; + + const jobs = await this.findRawJobs(ofHandles(handles)); + for (const job of jobs) { + const pointer = pointerOf(job); + if (!pointer) continue; + for (const targetLng of owedOnCancel(job, pointer.locales)) { + await this.context.report( + this.payload, + { handle: pointer.handle, targetLng }, + { state: "cancelled" } + ); } } + const ids = jobs.map((job) => job.id); + if (ids.length === 0) return; + await this.payload.jobs.cancel({ - where: { and: [this.ownJobs(), { id: { in: taskIds } }] }, + where: { and: [this.ownJobs(), { id: { in: ids } }] }, queue: this.config.queueName, }); await this.payload.delete({ collection: this.config.jobsCollection, - where: { and: [this.ownJobs(), { id: { in: taskIds } }] }, + where: { and: [this.ownJobs(), { id: { in: ids } }] }, }); } - async run(taskId: string): Promise { - const job = await this.findJobById(taskId); + async run(handle: string): Promise { + const job = await this.findJobByHandle(handle); if (!job) { return { success: false, error: "not_found" }; } @@ -276,11 +180,11 @@ export class PayloadJobsTaskRunner implements TaskRunner { if (job.processing && !this.isStale(job.updatedAt)) { return { success: false, error: "already_running" }; } - await this.clearPickerBlockers(taskId); + await this.clearPickerBlockers(job.id); const result = await this.payload.jobs.run({ queue: this.config.queueName, - where: { id: { equals: taskId } }, + where: { id: { equals: job.id } }, limit: 1, }); @@ -309,11 +213,11 @@ export class PayloadJobsTaskRunner implements TaskRunner { return result.docs.length; } - private async clearPickerBlockers(taskId: string): Promise { + private async clearPickerBlockers(id: string | number): Promise { await this.payload.update({ collection: this.config.jobsCollection, depth: 0, - where: { id: { equals: taskId } }, + where: { id: { equals: id } }, data: { processing: false, hasError: false, error: null, waitUntil: null }, }); } @@ -324,28 +228,6 @@ export class PayloadJobsTaskRunner implements TaskRunner { return Date.now() - parsed > this.config.staleJobTimeoutMs; } - async findByCollection( - collectionSlug: CollectionSlug, - filter?: Array | TaskFilter - ): Promise { - const { documentIds, excludeCompleted, excludeGivenUpBefore } = toTaskFilter(filter); - const wanted = documentIds ? new Set(documentIds.map(String)) : undefined; - if (wanted?.size === 0) return []; - - const narrowed: Where[] = [ofCollection(collectionSlug)]; - if (excludeCompleted) narrowed.push({ completedAt: { exists: false } }); - if (excludeGivenUpBefore) narrowed.push(settledSince(excludeGivenUpBefore)); - if (wanted) narrowed.push(ofDocuments([...wanted])); - - const jobs = await this.findRawJobs({ and: narrowed }); - return jobs - .filter((job) => { - const ref = readCollectionRef(job.input); - return ref.collectionSlug === collectionSlug && (!wanted || wanted.has(ref.collectionId)); - }) - .flatMap(normalizeJobLocales); - } - private ownJobs(): Where { return { or: [ @@ -355,8 +237,8 @@ export class PayloadJobsTaskRunner implements TaskRunner { }; } - private async findJobById(id: string, scope: RequestScope = {}): Promise { - const [job] = await this.findRawJobs({ id: { equals: id } }, scope); + private async findJobByHandle(handle: string): Promise { + const [job] = await this.findRawJobs(ofHandles([handle])); return job; } diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/PayloadJobsRunnerProvider.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/PayloadJobsRunnerProvider.test.ts index e28ddf060..0d7aa1e60 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/PayloadJobsRunnerProvider.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/PayloadJobsRunnerProvider.test.ts @@ -6,7 +6,6 @@ import type { TaskRunnerContext } from "../../TaskRunnerProvider.interface.js"; const minimalContext: TaskRunnerContext = { handler: vi.fn(), report: vi.fn().mockResolvedValue(undefined), - collections: ["pages"], }; // The config modifier only reads/writes `jobs` and `onInit`; build a minimal @@ -107,7 +106,7 @@ describe("PayloadJobsRunnerProvider", () => { expect(config.jobs?.enableConcurrencyControl).toBeUndefined(); }); - it("keys on the document when the host has enabled it", () => { + it("keys on the run when the host has enabled it", () => { const config = createPayloadJobsRunner().configure(minimalContext)( makeConfig({ jobs: { enableConcurrencyControl: true } }) ); @@ -115,8 +114,50 @@ describe("PayloadJobsRunnerProvider", () => { const concurrency = workflowOf(config).concurrency; expect(concurrency?.exclusive).toBe(true); expect( - concurrency?.key({ input: { collection_slug: "posts", collection_id: "7" } } as never) - ).toBe("posts:7"); + concurrency?.key({ input: { handle: "run-1" } } as never), + "one run per document is the plugin's rule now, so the queue only has to serialise a run" + ).toBe("run-1"); + }); + }); + + describe("the pointer the registered task hands on", () => { + const runTask = (handler: TaskRunnerContext["handler"], input: unknown) => { + const config = createPayloadJobsRunner().configure({ ...minimalContext, handler })( + makeConfig() + ); + const task = (config.jobs?.tasks ?? []).find( + (candidate) => candidate.slug === "translate_document" + ); + if (!task) throw new Error("the translate task was not registered"); + return (task.handler as unknown as (args: unknown) => Promise)({ + req: { payload: {} }, + job: { id: 42 }, + input, + }); + }; + + it("hands on the handle and the locale the job row names, and nothing else", async () => { + const handler = vi.fn().mockResolvedValue(undefined); + + await runTask(handler, { handle: "run-1", target_lng: "de" }); + + expect(handler.mock.calls[0][1]).toEqual({ + handle: "run-1", + targetLng: "de", + previousAttempts: 0, + }); + }); + + it("names no document, because the record is where the run's facts live", async () => { + const handler = vi.fn().mockResolvedValue(undefined); + + await runTask(handler, { handle: "run-1", target_lng: "de" }); + + expect(Object.keys(handler.mock.calls[0][1]).sort()).toEqual([ + "handle", + "previousAttempts", + "targetLng", + ]); }); }); }); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/PayloadJobsTaskRunner.cancel.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/PayloadJobsTaskRunner.cancel.test.ts index da057d30a..f113e58bb 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/PayloadJobsTaskRunner.cancel.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/PayloadJobsTaskRunner.cancel.test.ts @@ -6,7 +6,6 @@ import type { PayloadJobsRunnerConfig } from "../config.js"; const testContext = { handler: vi.fn().mockResolvedValue(undefined), - collections: [], report: vi.fn().mockResolvedValue(undefined), }; @@ -16,7 +15,12 @@ describe("PayloadJobsTaskRunner — cancel reaches only the plugin's own jobs", beforeEach(() => { payload = { - find: vi.fn().mockResolvedValue({ docs: [] }), + find: vi.fn().mockResolvedValue({ + docs: [ + { id: "job-1", input: { handle: "run-1", locales: ["de"] } }, + { id: "job-2", input: { handle: "run-2", locales: ["de"] } }, + ], + }), delete: vi.fn().mockResolvedValue(undefined), jobs: { cancel: vi.fn().mockResolvedValue(undefined) }, } as unknown as Payload & { jobs: { cancel: ReturnType } }; @@ -35,14 +39,14 @@ describe("PayloadJobsTaskRunner — cancel reaches only the plugin's own jobs", // The delete that follows is already scoped by `ownJobs()`. The cancel is not — it narrows by id // and queue only, so an id belonging to a host's own job on the same queue is marked cancelled. it("narrows the cancel by the plugin's own slugs, not by queue alone", async () => { - await runner.cancel(["job-1"]); + await runner.stop(["run-1"]); const [args] = payload.jobs.cancel.mock.calls[0] as [{ where: unknown }]; expect(JSON.stringify(args.where)).toContain("translate_document_locales"); }); - it("still narrows it by the ids it was given", async () => { - await runner.cancel(["job-1", "job-2"]); + it("still narrows it by the jobs those handles named", async () => { + await runner.stop(["run-1", "run-2"]); const [args] = payload.jobs.cancel.mock.calls[0] as [{ where: unknown }]; expect(JSON.stringify(args.where)).toContain("job-1"); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/PayloadJobsTaskRunner.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/PayloadJobsTaskRunner.test.ts index ba8b5c8b7..0362b62d1 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/PayloadJobsTaskRunner.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/PayloadJobsTaskRunner.test.ts @@ -8,10 +8,19 @@ import { asRequester } from "../../../../shared/payload/RequestScope.shapes.js"; const testContext = { handler: vi.fn().mockResolvedValue(undefined), - collections: [], report: vi.fn().mockResolvedValue(undefined), }; +const context = testContext; + +const createJob = (overrides: Partial = {}): PayloadJob => ({ + id: "job-123", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + input: { handle: "run-1", locales: ["de"] }, + ...overrides, +}); + describe("PayloadJobsTaskRunner", () => { let mockPayload: { find: ReturnType; @@ -30,6 +39,8 @@ describe("PayloadJobsTaskRunner", () => { let runner: PayloadJobsTaskRunner; beforeEach(() => { + testContext.report.mockClear(); + testContext.handler.mockClear(); mockPayload = { find: vi.fn().mockResolvedValue({ docs: [] }), delete: vi.fn().mockResolvedValue(undefined), @@ -70,50 +81,35 @@ describe("PayloadJobsTaskRunner", () => { ...overrides, }); - const createJob = (overrides: Partial = {}): PayloadJob => ({ - id: "job-123", - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - input: { - collection: { relationTo: "posts" as CollectionSlug, value: "doc-123" }, - source_lng: "en", - target_lng: "de", - }, - ...overrides, - }); - - /** A live workflow job whose settings match `createInput()`'s, so `pickHost` accepts it. */ const createLiveJob = (overrides: Partial = {}): PayloadJob => createJob({ - input: { - collection_slug: "posts", - collection_id: "doc-123", - source_lng: "en", - publish_on_translation: false, - targets: [{ lng: "de", filters: [] }], - }, + input: { handle: "run-live", locales: ["de"] }, ...overrides, }); describe("enqueue", () => { - it("reads only unfinished jobs when planning an enqueue", async () => { + it("reads no job at all before queuing one", async () => { await runner.enqueue([createInput()]); - const whereArg = mockPayload.find.mock.calls[0][0].where; - expect(whereArg).toEqual({ - and: [ - { - or: [ - { workflowSlug: { equals: "translate_document_locales" } }, - { taskSlug: { equals: "translate_document" } }, - ], - }, - { completedAt: { exists: false } }, - ], - }); + expect( + mockPayload.find, + "whether this document may take new work is the plugin's to decide, from its own record" + ).not.toHaveBeenCalled(); + }); + + it("queues its own run even for a document a live job already names", async () => { + mockPayload.find.mockResolvedValue({ docs: [createLiveJob({ id: "live-job" })] }); + + await runner.enqueue([createInput({ targetLng: "fr" })]); + + expect( + mockPayload.db.updateOne, + "nothing is merged into a run under way" + ).not.toHaveBeenCalled(); + expect(mockPayload.jobs.queue).toHaveBeenCalledTimes(1); }); - it("queues one workflow per document, carrying its locales", async () => { + it("queues one workflow per document, carrying a pointer and its locales", async () => { await runner.enqueue([createInput({ targetLng: "de" }), createInput({ targetLng: "fr" })]); expect(mockPayload.jobs.queue).toHaveBeenCalledTimes(1); @@ -121,34 +117,49 @@ describe("PayloadJobsTaskRunner", () => { workflow: "translate_document_locales", queue: "translations", waitUntil: undefined, - input: { - collection_slug: "posts", - collection_id: "doc-123", - source_lng: "en", - targets: [ - { lng: "de", filters: [] }, - { lng: "fr", filters: [] }, - ], - publish_on_translation: false, - requester_id: null, - requester_collection: null, - }, + input: { handle: expect.any(String), locales: ["de", "fr"] }, req: {}, }); }); - // The other half of the round trip `requesterRoundTrip.test.ts` reads back. Without a case - // carrying a real requester, both columns could be hardcoded to null and stay green — and a job - // row naming nobody is a job that writes without a permission check. - it("writes the requester who asked into the job row", async () => { - await runner.enqueue([createInput()], { - requester: asRequester(0, "editors"), - }); + it("states nothing about the run in the job row, because the record states it", async () => { + await runner.enqueue([createInput()], { requester: asRequester(0, "editors") }); - expect(mockPayload.jobs.queue).toHaveBeenCalledWith( - expect.objectContaining({ - input: expect.objectContaining({ requester_id: 0, requester_collection: "editors" }), - }) + const { input } = (mockPayload.jobs.queue as ReturnType).mock.calls[0][0]; + expect( + Object.keys(input).sort(), + "a job row that states a fact about the run states it twice" + ).toEqual(["handle", "locales"]); + }); + + it("tells the plugin what the run is about, so the record can hold it", async () => { + await runner.enqueue([createInput()], { requester: asRequester(0, "editors") }); + + expect(context.report).toHaveBeenCalledWith( + mockPayload, + { handle: expect.any(String), targetLng: "de" }, + { + state: "queued", + work: { + collectionSlug: "posts", + collectionId: "doc-123", + sourceLng: "en", + filters: undefined, + publishOnTranslation: false, + }, + }, + expect.objectContaining({ requester: { userId: 0, userCollection: "editors" } }) + ); + }); + + it("writes the record before the job exists, so a cron cannot outrun it", async () => { + await runner.enqueue([createInput()]); + + expect( + (context.report as ReturnType).mock.invocationCallOrder[0], + "a job picked up before its record would translate nothing and settle nothing" + ).toBeLessThan( + (mockPayload.jobs.queue as ReturnType).mock.invocationCallOrder[0] ); }); @@ -161,9 +172,6 @@ describe("PayloadJobsTaskRunner", () => { expect(mockPayload.jobs.queue).toHaveBeenCalledWith( expect.objectContaining({ req: { transactionID: "tx-7" } }) ); - expect(mockPayload.find).toHaveBeenCalledWith( - expect.objectContaining({ req: { transactionID: "tx-7" } }) - ); }); it("passes waitUntil through to payload.jobs.queue when set (debounce)", async () => { @@ -192,81 +200,8 @@ describe("PayloadJobsTaskRunner", () => { expect(mockPayload.jobs.queue).toHaveBeenCalledTimes(2); }); - it("adds the locale to a live job instead of queuing a second one", async () => { - const live = createLiveJob({ id: "live-job" }); - // First `find` builds the plan, the second verifies the write. - mockPayload.find.mockResolvedValueOnce({ docs: [live] }).mockResolvedValueOnce({ - docs: [ - { - ...live, - input: { - ...live.input, - targets: [ - { lng: "de", filters: [] }, - { lng: "fr", filters: [] }, - ], - }, - }, - ], - }); - - await runner.enqueue([createInput({ targetLng: "fr" })]); - - const write = mockPayload.db.updateOne.mock.calls[0][0]; - expect(write.collection).toBe("payload-jobs"); - expect(write.id).toBe("live-job"); - expect(write.data.input).toEqual({ - collection_slug: "posts", - collection_id: "doc-123", - source_lng: "en", - publish_on_translation: false, - targets: [ - { lng: "de", filters: [] }, - { lng: "fr", filters: [] }, - ], - }); - expect(mockPayload.jobs.queue).not.toHaveBeenCalled(); - }); - - it("does not extend a live job that belongs to a different document", async () => { - mockPayload.find.mockResolvedValue({ - docs: [ - createJob({ - id: "other-doc-job", - input: { - collection_slug: "posts", - collection_id: "doc-999", - source_lng: "en", - targets: [{ lng: "de", filters: [] }], - }, - }), - ], - }); - - await runner.enqueue([createInput({ targetLng: "fr" })]); - - expect(mockPayload.db.updateOne).not.toHaveBeenCalled(); - expect(mockPayload.jobs.queue).toHaveBeenCalledTimes(1); - }); - it("never cancels or deletes a job when enqueuing", async () => { - const live = createLiveJob({ - id: "live-job", - }); - mockPayload.find.mockResolvedValueOnce({ docs: [live] }).mockResolvedValueOnce({ - docs: [ - { - ...live, - input: { - ...live.input, - targets: [ - { lng: "de", filters: [] }, - { lng: "fr", filters: [] }, - ], - }, - }, - ], - }); + mockPayload.find.mockResolvedValue({ docs: [createLiveJob({ id: "live-job" })] }); await runner.enqueue([createInput({ targetLng: "fr" })]); @@ -274,189 +209,44 @@ describe("PayloadJobsTaskRunner", () => { expect(mockPayload.delete).not.toHaveBeenCalled(); }); - it("gives the locale its own job when the live one finished after the write landed", async () => { - const live = createLiveJob({ id: "live-job" }); - mockPayload.find.mockResolvedValueOnce({ docs: [live] }).mockResolvedValueOnce({ - docs: [ - { - ...live, - completedAt: "2026-01-01T00:00:01Z", - input: { - ...live.input, - targets: [ - { lng: "de", filters: [] }, - { lng: "fr", filters: [] }, - ], - }, - }, - ], - }); - - await runner.enqueue([createInput({ targetLng: "fr" })]); - - expect(mockPayload.jobs.queue).toHaveBeenCalledTimes(1); - expect(mockPayload.jobs.queue).toHaveBeenCalledWith( - expect.objectContaining({ - input: expect.objectContaining({ targets: [{ lng: "fr", filters: [] }] }), - }) - ); - }); - - it("retries the write once when a concurrent append replaced the list", async () => { - const live = createLiveJob({ id: "live-job" }); - const clobbered = { - ...live, - input: { - ...live.input, - targets: [ - { lng: "de", filters: [] }, - { lng: "es", filters: [] }, - ], - }, - }; - mockPayload.find - .mockResolvedValueOnce({ docs: [live] }) - .mockResolvedValueOnce({ docs: [clobbered] }) - .mockResolvedValueOnce({ - docs: [ - { - ...live, - input: { - ...live.input, - targets: [ - { lng: "de", filters: [] }, - { lng: "es", filters: [] }, - { lng: "fr", filters: [] }, - ], - }, - }, - ], - }); - - await runner.enqueue([createInput({ targetLng: "fr" })]); - - expect(mockPayload.db.updateOne).toHaveBeenCalledTimes(2); - expect(mockPayload.jobs.queue).not.toHaveBeenCalled(); - }); - - it("gives the locale its own job when even the retry does not land it", async () => { - const live = createLiveJob({ id: "live-job" }); - const withoutIt = { - ...live, - input: { - ...live.input, - targets: [ - { lng: "de", filters: [] }, - { lng: "es", filters: [] }, - ], - }, - }; - mockPayload.find - .mockResolvedValueOnce({ docs: [live] }) - .mockResolvedValueOnce({ docs: [withoutIt] }) - .mockResolvedValueOnce({ docs: [withoutIt] }); - - await runner.enqueue([createInput({ targetLng: "fr" })]); - - expect(mockPayload.jobs.queue).toHaveBeenCalledWith( - expect.objectContaining({ - input: expect.objectContaining({ targets: [{ lng: "fr", filters: [] }] }), - }) - ); - }); - - it("pushes a not-yet-started job's debounce out to this request's", async () => { - const live = createLiveJob({ id: "live-job" }); - mockPayload.find.mockResolvedValue({ docs: [live] }); - const waitUntil = new Date("2026-02-02T00:00:00.000Z"); - - await runner.enqueue([createInput({ targetLng: "de", waitUntil })]); - - expect(mockPayload.db.updateOne.mock.calls[0][0].data).toMatchObject({ - waitUntil: "2026-02-02T00:00:00.000Z", - }); - }); - - it("leaves a running job's schedule alone", async () => { - const running = createLiveJob({ id: "live-job", processing: true }); - mockPayload.find.mockResolvedValue({ docs: [running] }); - - await runner.enqueue([ - createInput({ targetLng: "de", waitUntil: new Date("2026-02-02T00:00:00.000Z") }), - ]); - - expect(mockPayload.db.updateOne).not.toHaveBeenCalled(); - }); - - it("queues alongside a running job when the host enabled concurrency control", async () => { - mockPayload.config.jobs = { enableConcurrencyControl: true }; - mockPayload.find.mockResolvedValue({ - docs: [ - createLiveJob({ - id: "running-job", - processing: true, - }), - ], - }); - - await runner.enqueue([createInput({ targetLng: "fr" })]); - - expect(mockPayload.db.updateOne).not.toHaveBeenCalled(); - expect(mockPayload.jobs.queue).toHaveBeenCalledTimes(1); - }); - - it("reads the job table once for the whole batch, however many documents it spans", async () => { + it("queues one run per document, however many the batch spans", async () => { await runner.enqueue([ createInput({ collectionSlug: "posts" as CollectionSlug, collectionId: "post-1" }), createInput({ collectionSlug: "posts" as CollectionSlug, collectionId: "post-2" }), createInput({ collectionSlug: "pages" as CollectionSlug, collectionId: "page-1" }), ]); - expect(mockPayload.find).toHaveBeenCalledTimes(1); expect(mockPayload.jobs.queue).toHaveBeenCalledTimes(3); }); - it("stores the reference as flat text, coercing the id to a string", async () => { - // The job input stores a flat text reference (ID-agnostic) instead of a - // Payload relationship field. The relationship field validated the value - // type against the collection's ID type, so a string id for a number-id - // collection silently failed validation and left the job stuck. Text - // storage sidesteps that; the id is normalized to a string on write. - // See docs/DEPRECATIONS.md#jobs-input-collection-field - - // A number id (e.g. from a number-id collection) is stored as a string + it("names the document to the record as a string, whatever shape its id had", async () => { await runner.enqueue([createInput({ collectionId: 5 as unknown as string })]); - expect(mockPayload.jobs.queue).toHaveBeenCalledWith( - expect.objectContaining({ - input: expect.objectContaining({ - collection_slug: "posts", - collection_id: "5", - }), - }) - ); - mockPayload.jobs.queue.mockClear(); - - // A string id (uuid/text collection) round-trips unchanged - await runner.enqueue([createInput({ collectionId: "uuid-abc" })]); - expect(mockPayload.jobs.queue).toHaveBeenCalledWith( - expect.objectContaining({ - input: expect.objectContaining({ - collection_slug: "posts", - collection_id: "uuid-abc", - }), - }) - ); - // The legacy relationship field is no longer written - const lastCall = mockPayload.jobs.queue.mock.calls.at(-1)?.[0]; - expect(lastCall?.input).not.toHaveProperty("collection"); + expect( + (context.report as ReturnType).mock.calls[0][2].work, + "the plugin is ID-agnostic: every document id is a string to it" + ).toMatchObject({ collectionSlug: "posts", collectionId: "5" }); }); }); describe("cancel", () => { - it("cancels jobs by ids", async () => { - await runner.cancel(["job-1", "job-2"]); + it("cancels the jobs these handles name", async () => { + mockPayload.find.mockResolvedValue({ + docs: [ + { id: "job-1", input: { handle: "run-1", locales: ["de"] } }, + { id: "job-2", input: { handle: "run-2", locales: ["de"] } }, + ], + }); + await runner.stop(["run-1", "run-2"]); + + expect(mockPayload.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + and: expect.arrayContaining([{ "input.handle": { in: ["run-1", "run-2"] } }]), + }), + }) + ); expect(mockPayload.jobs.cancel).toHaveBeenCalledWith({ where: { and: [ @@ -487,8 +277,12 @@ describe("PayloadJobsTaskRunner", () => { }); }); - it("deletes only this plugin's jobs, whatever ids it is handed", async () => { - await runner.cancel(["someone-elses-job"]); + it("deletes only this plugin's jobs, whatever handle it is given", async () => { + mockPayload.find.mockResolvedValue({ + docs: [{ id: "job-1", input: { handle: "run-1", locales: ["de"] } }], + }); + + await runner.stop(["run-1"]); const where = mockPayload.delete.mock.calls[0][0].where as { and?: unknown[] }; expect(where.and?.[0]).toEqual({ @@ -499,8 +293,15 @@ describe("PayloadJobsTaskRunner", () => { }); }); + it("touches nothing for a handle no job of ours carries", async () => { + await runner.stop(["someone-elses-handle"]); + + expect(mockPayload.jobs.cancel).not.toHaveBeenCalled(); + expect(mockPayload.delete).not.toHaveBeenCalled(); + }); + it("does nothing for empty array", async () => { - await runner.cancel([]); + await runner.stop([]); expect(mockPayload.jobs.cancel).not.toHaveBeenCalled(); expect(mockPayload.delete).not.toHaveBeenCalled(); @@ -547,12 +348,12 @@ describe("PayloadJobsTaskRunner", () => { }); mockPayload.find.mockResolvedValue({ docs: [partiallyFailed] }); - const result = await runner.run("job-123"); + const result = await runner.run("run-1"); expect(result).toEqual({ success: true }); expect(mockPayload.jobs.run).toHaveBeenCalledWith({ queue: "translations", - where: { id: { equals: "job-123" } }, + where: { id: { equals: partiallyFailed.id } }, limit: 1, }); }); @@ -654,12 +455,12 @@ describe("PayloadJobsTaskRunner", () => { const pendingJob = createJob(); mockPayload.find.mockResolvedValue({ docs: [pendingJob] }); - const result = await runner.run("job-123"); + const result = await runner.run("run-1"); expect(result).toEqual({ success: true }); expect(mockPayload.jobs.run).toHaveBeenCalledWith({ queue: "translations", - where: { id: { equals: "job-123" } }, + where: { id: { equals: pendingJob.id } }, limit: 1, }); expect(mockPayload.update.mock.invocationCallOrder[0]).toBeLessThan( @@ -675,7 +476,7 @@ describe("PayloadJobsTaskRunner", () => { { taskSlug: { equals: "translate_document" } }, ], }, - { id: { equals: "job-123" } }, + { "input.handle": { in: ["run-1"] } }, ], }, }) @@ -726,8 +527,6 @@ describe("PayloadJobsTaskRunner", () => { expect(arg.collection).toBe("payload-jobs"); expect(arg.data).toEqual({ processing: false }); expect(arg.depth).toBe(0); - // Narrows by real payload-jobs columns only (no JSON-path traversal, - // which would hit the drizzle SQLite issue documented in findByCollection). const expectedCutoff = new Date( Date.parse("2026-01-01T00:00:00.000Z") - 300_000 ).toISOString(); @@ -751,197 +550,4 @@ describe("PayloadJobsTaskRunner", () => { expect(await runner.reclaimStaleJobs()).toBe(0); }); }); - - describe("findByCollection", () => { - it("finds tasks by collection slug", async () => { - // Mock returns only jobs matching the collection (simulating DB where clause) - const jobs = [ - createJob({ - id: "job-1", - input: { collection: { relationTo: "posts", value: "doc-1" } }, - }), - createJob({ - id: "job-2", - input: { collection: { relationTo: "posts", value: "doc-2" } }, - }), - ]; - mockPayload.find.mockResolvedValue({ docs: jobs }); - - const tasks = await runner.findByCollection("posts"); - - expect(tasks).toHaveLength(2); - expect(tasks[0].id).toBe("job-1"); - expect(tasks[1].id).toBe("job-2"); - }); - - it("filters by document ids when provided", async () => { - // Mock returns only jobs matching collection and document ids (simulating DB where clause) - const jobs = [ - createJob({ - id: "job-1", - input: { collection: { relationTo: "posts", value: "doc-1" } }, - }), - createJob({ - id: "job-2", - input: { collection: { relationTo: "posts", value: "doc-2" } }, - }), - ]; - mockPayload.find.mockResolvedValue({ docs: jobs }); - - const tasks = await runner.findByCollection("posts" as CollectionSlug, ["doc-1", "doc-2"]); - - expect(tasks).toHaveLength(2); - expect(tasks[0].id).toBe("job-1"); - expect(tasks[1].id).toBe("job-2"); - }); - - it("returns normalized tasks", async () => { - const job = createJob({ - id: "job-123", - completedAt: "2024-01-01T01:00:00Z", - }); - mockPayload.find.mockResolvedValue({ docs: [job] }); - - const tasks = await runner.findByCollection("posts" as CollectionSlug); - - expect(tasks[0]).toMatchObject({ - id: "job-123", - status: "completed", - input: { - collectionSlug: "posts", - collectionId: "doc-123", - }, - }); - }); - - it("asks the database for the collection and the documents, in both stored shapes", async () => { - await runner.findByCollection("posts" as CollectionSlug, [5, 6]); - - const whereArg = mockPayload.find.mock.calls[0][0].where; - expect(whereArg).toEqual({ - and: [ - { - or: [ - { workflowSlug: { equals: "translate_document_locales" } }, - { taskSlug: { equals: "translate_document" } }, - ], - }, - { - and: [ - { - or: [ - { "input.collection_slug": { equals: "posts" } }, - { "input.collection.relationTo": { equals: "posts" } }, - ], - }, - { - or: [ - { "input.collection_id": { in: ["5", 5, "6", 6] } }, - { "input.collection.value": { in: ["5", 5, "6", 6] } }, - ], - }, - ], - }, - ], - }); - }); - - it("offers a document id in both forms the store may hold", async () => { - await runner.findByCollection("posts" as CollectionSlug, [5, 6]); - - const whereArg = mockPayload.find.mock.calls[0][0].where; - expect( - JSON.stringify(whereArg), - "the legacy relationship stored a numeric collection's id as a number, and a number never equals a string" - ).toContain('"in":["5",5,"6",6]'); - }); - - it("keeps its own-jobs condition when it narrows, rather than replacing it", async () => { - await runner.findByCollection("posts" as CollectionSlug); - - const whereArg = mockPayload.find.mock.calls[0][0].where; - expect( - whereArg.and[0], - "a workflow row carries no taskSlug, so dropping this or returns nothing" - ).toEqual({ - or: [ - { workflowSlug: { equals: "translate_document_locales" } }, - { taskSlug: { equals: "translate_document" } }, - ], - }); - }); - - it("adds completedAt to the where clause when asked to exclude finished jobs", async () => { - await runner.findByCollection("posts" as CollectionSlug, { excludeCompleted: true }); - - const whereArg = mockPayload.find.mock.calls[0][0].where; - expect(whereArg.and[1].and).toContainEqual({ completedAt: { exists: false } }); - expect( - JSON.stringify(whereArg), - "nothing asked about a document, so nothing narrows by one" - ).not.toContain("collection_id"); - }); - - it("reads the deprecated array form as documentIds", async () => { - const jobs = [ - createJob({ id: "job-1", input: { collection_slug: "posts", collection_id: "doc-1" } }), - createJob({ id: "job-2", input: { collection_slug: "posts", collection_id: "doc-2" } }), - ]; - mockPayload.find.mockResolvedValue({ docs: jobs }); - - const positional = await runner.findByCollection("posts" as CollectionSlug, ["doc-1"]); - const object = await runner.findByCollection("posts" as CollectionSlug, { - documentIds: ["doc-1"], - }); - - expect(positional.map((t) => t.id)).toEqual(["job-1"]); - expect(object.map((t) => t.id)).toEqual(positional.map((t) => t.id)); - }); - - it("matches jobs stored in both the legacy and the new shape", async () => { - // payload.find returns the full per-task set; the runner narrows it - // client-side by slug + id. The result must span both stored shapes: - // legacy `collection: { relationTo, value }` (value possibly a number) - // and the new flat `collection_slug` / `collection_id` text fields. - const legacyJob = createJob({ - id: "legacy-job", - input: { - collection: { relationTo: "posts" as CollectionSlug, value: 5 }, - }, - }); - const newJob = createJob({ - id: "new-job", - input: { collection_slug: "posts", collection_id: "7" }, - }); - const unrelatedJob = createJob({ - id: "unrelated", - input: { collection_slug: "posts", collection_id: "99" }, - }); - mockPayload.find.mockResolvedValue({ - docs: [legacyJob, newJob, unrelatedJob], - }); - - const tasks = await runner.findByCollection("posts" as CollectionSlug, [5, "7"]); - - expect(tasks.map((t) => t.id).sort()).toEqual(["legacy-job", "new-job"]); - }); - - it("returns every task in the collection when documentIds is omitted", async () => { - const jobs = [ - createJob({ - id: "a", - input: { collection: { relationTo: "posts", value: "1" } }, - }), - createJob({ - id: "b", - input: { collection: { relationTo: "posts", value: "2" } }, - }), - ]; - mockPayload.find.mockResolvedValue({ docs: jobs }); - - const tasks = await runner.findByCollection("posts" as CollectionSlug); - - expect(tasks.map((t) => t.id)).toEqual(["a", "b"]); - }); - }); }); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts deleted file mode 100644 index b66ece7d4..000000000 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/alreadyCovered.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { alreadyCovered } from "../model/alreadyCovered.js"; -import type { StoredRunTarget } from "../store/index.js"; -import type { JobLogEntry, PayloadJob } from "../store/types.js"; - -const translated = (targetLng: string, completedAt = "2024-01-01T00:01:00Z"): JobLogEntry => ({ - state: "succeeded", - completedAt, - input: { target_lng: targetLng }, -}); - -const attemptFailed = (targetLng: string, completedAt = "2024-01-01T00:01:00Z"): JobLogEntry => ({ - state: "failed", - completedAt, - input: { target_lng: targetLng }, -}); - -const runCarrying = (targetLngs: string[], log?: JobLogEntry[]): PayloadJob => ({ - id: "job-live", - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - processing: true, - log, - input: { - collection_slug: "posts", - collection_id: "doc-1", - source_lng: "en", - target_lngs: targetLngs, - }, -}); - -describe("alreadyCovered", () => { - describe("a locale the run lists and has not translated", () => { - it("covers it when it has not been attempted, while another locale of the same run is translated", () => { - const run = runCarrying(["de", "fr"], [translated("de")]); - expect(alreadyCovered(run, [{ lng: "fr", filters: [] }])).toEqual(["fr"]); - }); - - it("covers it when its attempt failed, because the run will come back to it", () => { - const run = runCarrying(["de", "fr"], [translated("de"), attemptFailed("fr")]); - expect(alreadyCovered(run, [{ lng: "fr", filters: [] }])).toEqual(["fr"]); - }); - }); - - describe("a locale the run lists and has translated", () => { - it("does not cover it, while the run still has an unreached locale", () => { - const run = runCarrying(["de", "fr"], [translated("de")]); - expect(alreadyCovered(run, [{ lng: "de", filters: [] }])).toEqual([]); - }); - }); - - describe("several records for one locale", () => { - it("does not cover a locale that failed and was then translated", () => { - const run = runCarrying( - ["de", "fr"], - [attemptFailed("de", "2024-01-01T00:01:00Z"), translated("de", "2024-01-01T00:03:00Z")] - ); - expect(alreadyCovered(run, [{ lng: "de", filters: [] }])).toEqual([]); - }); - - it("does not cover a locale that was translated and has a later failure", () => { - const run = runCarrying( - ["de", "fr"], - [translated("de", "2024-01-01T00:01:00Z"), attemptFailed("de", "2024-01-01T00:03:00Z")] - ); - expect(alreadyCovered(run, [{ lng: "de", filters: [] }])).toEqual([]); - }); - }); - - describe("a record that names no locale", () => { - it("ignores it, so the locale it cannot identify stays covered", () => { - const run = runCarrying( - ["de", "fr"], - [ - { state: "succeeded", completedAt: "2024-01-01T00:01:00Z" }, - { state: "succeeded", completedAt: "2024-01-01T00:02:00Z", input: {} }, - ] - ); - expect(alreadyCovered(run, [{ lng: "de", filters: [] }])).toEqual(["de"]); - }); - }); - - describe("a locale the run does not list", () => { - it("does not cover it, though the run has locales it has not reached", () => { - const run = runCarrying(["de", "fr"]); - expect(alreadyCovered(run, [{ lng: "it", filters: [] }])).toEqual([]); - }); - }); - - describe("a locale asked for twice", () => { - it("answers it once", () => { - const run = runCarrying(["de", "fr"]); - expect( - alreadyCovered(run, [ - { lng: "de", filters: [] }, - { lng: "de", filters: [] }, - ]) - ).toEqual(["de"]); - }); - }); - - describe("locales the request did not ask for", () => { - it("leaves out a locale the run carries and has not reached but nobody requested", () => { - const run = runCarrying(["de", "fr"]); - expect(alreadyCovered(run, [{ lng: "de", filters: [] }])).toEqual(["de"]); - }); - }); - - describe("order", () => { - it("follows the request, not the order the run lists its locales in", () => { - const run = runCarrying(["de", "fr", "es"]); - expect( - alreadyCovered(run, [ - { lng: "es", filters: [] }, - { lng: "de", filters: [] }, - { lng: "fr", filters: [] }, - ]) - ).toEqual(["es", "de", "fr"]); - }); - }); - - describe("empty request", () => { - it("yields nothing, though the run carries locales it has not reached", () => { - const run = runCarrying(["de", "fr"]); - expect(alreadyCovered(run, [])).toEqual([]); - }); - }); - - describe("a run carrying no locales", () => { - it("yields nothing", () => { - const run = runCarrying([]); - expect( - alreadyCovered(run, [ - { lng: "de", filters: [] }, - { lng: "fr", filters: [] }, - ]) - ).toEqual([]); - }); - }); - - describe("a locale the run lists while meaning to translate less", () => { - const listing = (target: StoredRunTarget) => - ({ - ...runCarrying([target.lng]), - input: { - collection_slug: "posts", - collection_id: "doc-1", - source_lng: "en", - targets: [target], - }, - }) as PayloadJob; - - it("is not covered when the run named leaves and the request named none", () => { - expect( - alreadyCovered( - listing({ lng: "de", filters: [{ kind: "leaves", addresses: ["title"] }] }), - [{ lng: "de", filters: [] }] - ) - ).toEqual([]); - }); - - it("is not covered when the request named a leaf the run did not", () => { - const run = listing({ lng: "de", filters: [{ kind: "leaves", addresses: ["title"] }] }); - - expect( - alreadyCovered(run, [{ lng: "de", filters: [{ kind: "leaves", addresses: ["body"] }] }]) - ).toEqual([]); - }); - - it("is covered when the run named every leaf the request did", () => { - const run = listing({ - lng: "de", - filters: [{ kind: "leaves", addresses: ["title", "body"] }], - }); - - expect( - alreadyCovered(run, [{ lng: "de", filters: [{ kind: "leaves", addresses: ["body"] }] }]) - ).toEqual(["de"]); - }); - - it("is not covered when the run narrows by a rule the request did not name", () => { - const run = listing({ lng: "de", filters: [{ kind: "rule", name: "stale" }] }); - - expect(alreadyCovered(run, [{ lng: "de", filters: [] }])).toEqual([]); - }); - - it("is covered when the request names the same rule", () => { - const run = listing({ lng: "de", filters: [{ kind: "rule", name: "stale" }] }); - - expect( - alreadyCovered(run, [{ lng: "de", filters: [{ kind: "rule", name: "stale" }] }]) - ).toEqual(["de"]); - }); - - it("is covered when the run narrows by nothing and the request does", () => { - expect( - alreadyCovered(listing({ lng: "de", filters: [] }), [ - { lng: "de", filters: [{ kind: "rule", name: "stale" }] }, - ]) - ).toEqual(["de"]); - }); - }); -}); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/assignmentOf.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/assignmentOf.test.ts deleted file mode 100644 index 02438d57f..000000000 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/assignmentOf.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { assignmentOf } from "../store/assignmentOf.js"; -import type { PayloadJob } from "../store/types.js"; - -const job = (input: PayloadJob["input"]): PayloadJob => ({ - id: "job-1", - createdAt: "2026-10-24T00:00:00.000Z", - updatedAt: "2026-10-24T00:00:00.000Z", - input, -}); - -const described = { - collection_slug: "posts", - collection_id: "doc-1", - source_lng: "en", -}; - -describe("one locale of a stored run, as the host is told about it", () => { - it("describes the document, the languages and the run", () => { - expect(assignmentOf(job(described), "de")).toEqual({ - collectionSlug: "posts", - collectionId: "doc-1", - sourceLng: "en", - targetLng: "de", - handle: "job-1", - }); - }); - - it("yields nothing when the row names no source language", () => { - expect( - assignmentOf(job({ collection_slug: "posts", collection_id: "doc-1" }), "de") - ).toBeUndefined(); - }); - - it("yields nothing when the row names no document", () => { - expect( - assignmentOf(job({ source_lng: "en" }), "de"), - "a guessed collection would reach the host as fact" - ).toBeUndefined(); - }); -}); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/attemptCount.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/attemptCount.test.ts index e705d281a..02840eec9 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/attemptCount.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/attemptCount.test.ts @@ -12,7 +12,6 @@ const registeredTask = (handler: TaskRunnerContext["handler"]) => { const context: TaskRunnerContext = { handler, report: vi.fn().mockResolvedValue(undefined), - collections: ["posts"], }; createPayloadJobsRunner().configure(context)(config); const task = config.jobs?.tasks?.find((candidate) => candidate.slug === TASK_SLUG); @@ -41,21 +40,21 @@ const runTheTask = async (job: unknown): Promise => { describe("what a run is told about the attempt it is on", () => { it("says zero on a first run, where Payload has recorded nothing yet", async () => { - expect((await runTheTask(undefined)).previousAttempts).toBe(0); + expect((await runTheTask({ id: "job-1" })).previousAttempts).toBe(0); }); it("says zero when the row exists but this locale has not been tried", async () => { - expect((await runTheTask({ taskStatus: {} })).previousAttempts).toBe(0); + expect((await runTheTask({ id: "job-1", taskStatus: {} })).previousAttempts).toBe(0); }); it("says one on the first retry of that locale", async () => { - const job = { taskStatus: { [TASK_SLUG]: { de: { totalTried: 1 } } } }; + const job = { id: "job-1", taskStatus: { [TASK_SLUG]: { de: { totalTried: 1 } } } }; expect((await runTheTask(job)).previousAttempts).toBe(1); }); it("counts the locale that is running, not a sibling locale of the same run", async () => { - const job = { taskStatus: { [TASK_SLUG]: { fr: { totalTried: 3 } } } }; + const job = { id: "job-1", taskStatus: { [TASK_SLUG]: { fr: { totalTried: 3 } } } }; expect((await runTheTask(job)).previousAttempts).toBe(0); }); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/covers.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/covers.test.ts deleted file mode 100644 index 07cf124b4..000000000 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/covers.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { TranslationFilter } from "../../../../../core/domain/selection/index.js"; -import { covers } from "../model/covers.js"; - -const leaves = (...addresses: string[]): TranslationFilter => ({ kind: "leaves", addresses }); -const rule = (name: string): TranslationFilter => ({ kind: "rule", name }); - -describe("whether a live run already translates what a request asks for", () => { - it("a run that narrows by nothing covers an ask that narrows by nothing", () => { - expect(covers([], [])).toBe(true); - }); - - it("a run that narrows by nothing covers an ask that names leaves", () => { - expect(covers([], [leaves("title")])).toBe(true); - }); - - it("a run that narrows by nothing covers an ask that names a rule", () => { - expect(covers([], [rule("stale")])).toBe(true); - }); - - it("a run that names leaves does not cover an ask that names none", () => { - expect(covers([leaves("title")], [])).toBe(false); - }); - - it("a run that names leaves covers an ask naming a subset of them", () => { - expect(covers([leaves("title", "body")], [leaves("body")])).toBe(true); - }); - - it("a run that names leaves does not cover an ask naming one it left out", () => { - expect(covers([leaves("title")], [leaves("body")])).toBe(false); - }); - - it("a run that names leaves does not cover an ask naming those and more", () => { - expect(covers([leaves("title")], [leaves("title", "body")])).toBe(false); - }); - - it("a run that names the same leaves covers the ask", () => { - expect(covers([leaves("title")], [leaves("title")])).toBe(true); - }); - - it("a run that names a rule covers an ask naming the same rule", () => { - expect(covers([rule("stale")], [rule("stale")])).toBe(true); - }); - - it("a run that names a rule does not cover an ask naming a different one", () => { - expect(covers([rule("stale")], [rule("other")])).toBe(false); - }); - - it("a run that names a rule does not cover an ask that names only leaves", () => { - expect(covers([rule("stale")], [leaves("title")])).toBe(false); - }); - - it("a run that names leaves does not cover an ask that names only a rule", () => { - expect(covers([leaves("title")], [rule("stale")])).toBe(false); - }); - - it("covers an ask only when every filter the run carries is accepted", () => { - const run = [leaves("title", "body"), rule("stale")]; - - expect(covers(run, [leaves("body"), rule("stale")])).toBe(true); - expect(covers(run, [leaves("body")])).toBe(false); - }); - - it("answers the same however the filters are ordered", () => { - const run = [rule("stale"), leaves("title", "body")]; - const ask = [leaves("body"), rule("stale")]; - - expect(covers(run, ask)).toBe(covers([...run].reverse(), [...ask].reverse())); - }); -}); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/jobInputFields.baseline.json b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/jobInputFields.baseline.json index b73776d46..860c47eef 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/jobInputFields.baseline.json +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/jobInputFields.baseline.json @@ -2,30 +2,7 @@ "task": [ { "type": "text", - "name": "collection_slug", - "required": true - }, - { - "type": "text", - "name": "collection_id", - "required": true - }, - { - "type": "relationship", - "name": "collection", - "relationTo": [ - "posts", - "docs" - ], - "required": false, - "admin": { - "readOnly": true, - "description": "Deprecated. See docs/DEPRECATIONS.md#jobs-input-collection-field" - } - }, - { - "type": "text", - "name": "source_lng", + "name": "handle", "maxLength": 256, "required": true }, @@ -34,65 +11,19 @@ "name": "target_lng", "maxLength": 256, "required": true - }, - { - "type": "json", - "name": "filters", - "defaultValue": [] - }, - { - "type": "checkbox", - "name": "publish_on_translation", - "defaultValue": false } ], "workflow": [ { "type": "text", - "name": "collection_slug", - "required": true - }, - { - "type": "text", - "name": "collection_id", - "required": true - }, - { - "type": "relationship", - "name": "collection", - "relationTo": [ - "posts", - "docs" - ], - "required": false, - "admin": { - "readOnly": true, - "description": "Deprecated. See docs/DEPRECATIONS.md#jobs-input-collection-field" - } - }, - { - "type": "text", - "name": "source_lng", + "name": "handle", "maxLength": 256, "required": true }, - { - "type": "checkbox", - "name": "publish_on_translation", - "defaultValue": false - }, { "type": "json", - "name": "targets", + "name": "locales", "required": true - }, - { - "type": "text", - "name": "requester_id" - }, - { - "type": "text", - "name": "requester_collection" } ] } diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/jobInputFields.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/jobInputFields.test.ts index 76a8cb657..858aebbd1 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/jobInputFields.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/jobInputFields.test.ts @@ -15,7 +15,6 @@ import baseline from "./jobInputFields.baseline.json"; const context: TaskRunnerContext = { handler: vi.fn(), report: vi.fn().mockResolvedValue(undefined), - collections: ["posts", "docs"], }; const names = (fields: unknown) => (fields as Array<{ name?: string }>).map((field) => field.name); @@ -38,21 +37,40 @@ describe("the field lists handed to Payload", () => { expect(registered().workflow).toEqual(baseline.workflow); }); - it("names the run's target list only on the workflow, and one locale only on the task", () => { + it("names the run's locale list only on the workflow, and one locale only on the task", () => { const { task, workflow } = registered(); expect(names(task)).toContain("target_lng"); - expect(names(task)).not.toContain("targets"); - expect(names(workflow)).toContain("targets"); + expect(names(task)).not.toContain("locales"); + expect(names(workflow)).toContain("locales"); expect(names(workflow)).not.toContain("target_lng"); }); - it("carries neither a strategy nor the two members filters replaced", () => { + it("carries the handle on both, because that is what every row is read by", () => { const { task, workflow } = registered(); - expect(names(task)).not.toContain("strategy"); - expect(names(workflow)).not.toContain("strategy"); - expect(names(task)).not.toContain("selection"); - expect(names(task)).not.toContain("criteria"); + expect(names(task)).toContain("handle"); + expect(names(workflow)).toContain("handle"); + }); + + it("states no fact about the run that the record is the source of truth for", () => { + const stored = [...names(registered().task), ...names(registered().workflow)]; + + for (const fact of [ + "collection_slug", + "collection_id", + "collection", + "source_lng", + "filters", + "targets", + "publish_on_translation", + "requester_id", + "requester_collection", + "strategy", + "selection", + "criteria", + ]) { + expect(stored, `a job row that states "${fact}" states it twice`).not.toContain(fact); + } }); }); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/localesAsTheyStand.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/localesAsTheyStand.test.ts deleted file mode 100644 index 45bb533b0..000000000 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/localesAsTheyStand.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, it, expect } from "vitest"; - -import { localesAsTheyStand } from "../model/localesAsTheyStand.js"; - -const runOf = (targets: string[]) => ({ - input: { - collection_slug: "docs", - collection_id: "1", - source_lng: "en", - target_lngs: targets, - }, -}); - -describe("localesAsTheyStand", () => { - it("yields every locale the run lists, in order", () => { - const yielded = [...localesAsTheyStand(runOf(["de", "fr", "es"]))].map((run) => run.target); - - expect(yielded).toEqual(["de", "fr", "es"]); - }); - - it("yields nothing when the run lists none", () => { - expect([...localesAsTheyStand({ input: {} })]).toEqual([]); - }); - - it("yields a locale appended after the walk began", () => { - const job = runOf(["de"]); - const walk = localesAsTheyStand(job); - - expect(walk.next().value?.target).toBe("de"); - job.input.target_lngs.push("fr"); - - expect( - walk.next().value?.target, - "a second request appends to the row of a run already in flight" - ).toBe("fr"); - }); - - it("yields a locale once even when the run lists it twice", () => { - // Nothing in the plugin writes a duplicate — planEnqueue and extendJob both de-duplicate — but - // the row is a json column, and a run that translated one locale twice would announce it twice. - const yielded = [...localesAsTheyStand(runOf(["de", "fr", "de"]))].map((run) => run.target); - - expect(yielded).toEqual(["de", "fr"]); - }); - - it("hands each task the run's shared fields and its own locale", () => { - const [first] = [...localesAsTheyStand(runOf(["de"]))]; - - expect(first?.input).toEqual({ - collection_slug: "docs", - collection_id: "1", - source_lng: "en", - target_lng: "de", - filters: [], - }); - }); - - it("does not hand a task the whole locale list", () => { - const [first] = [...localesAsTheyStand(runOf(["de", "fr"]))]; - - expect( - first?.input, - "a task translates one locale and must not see the run's list" - ).not.toHaveProperty("target_lngs"); - }); -}); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/normalizeJob.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/normalizeJob.test.ts deleted file mode 100644 index b1ae1e2e4..000000000 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/normalizeJob.test.ts +++ /dev/null @@ -1,411 +0,0 @@ -import { describe, it, expect } from "vitest"; -import type { CollectionSlug } from "payload"; -import { normalizeJob, normalizeJobLocales } from "../store/normalizeJob.js"; -import { runTargets } from "../store/runTargets.js"; -import type { PayloadJob } from "../store/types.js"; - -describe("normalizeJob", () => { - const baseJob: PayloadJob = { - id: "job-123", - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - input: { - collection: { - relationTo: "posts" as CollectionSlug, - value: "doc-456", - }, - source_lng: "en", - target_lng: "de", - }, - }; - - describe("basic transformation", () => { - it("transforms job to task with correct id", () => { - const task = normalizeJob(baseJob); - expect(task.id).toBe("job-123"); - }); - - it("transforms job to task with correct input", () => { - const task = normalizeJob(baseJob); - expect(task.input).toEqual({ - collectionSlug: "posts", - collectionId: "doc-456", - sourceLng: "en", - targetLng: "de", - publishOnTranslation: false, - }); - }); - - it("transforms job to task with correct timestamps", () => { - const task = normalizeJob(baseJob); - expect(task.createdAt).toBe("2024-01-01T00:00:00Z"); - expect(task.updatedAt).toBe("2024-01-01T00:00:00Z"); - }); - }); - - describe("id-agnostic shape handling", () => { - it("reads the new flat-text reference shape", () => { - const job: PayloadJob = { - ...baseJob, - input: { - collection_slug: "posts", - collection_id: "doc-789", - source_lng: "en", - target_lng: "de", - }, - }; - const task = normalizeJob(job); - expect(task.input.collectionSlug).toBe("posts"); - expect(task.input.collectionId).toBe("doc-789"); - }); - - it("falls back to the legacy relationship shape, coercing the id to a string", () => { - const job: PayloadJob = { - ...baseJob, - input: { - collection: { relationTo: "posts" as CollectionSlug, value: 5 }, - source_lng: "en", - target_lng: "de", - }, - }; - const task = normalizeJob(job); - expect(task.input.collectionSlug).toBe("posts"); - expect(task.input.collectionId).toBe("5"); - }); - - it("prefers the new shape over the legacy shape when both are present", () => { - const job: PayloadJob = { - ...baseJob, - input: { - collection_slug: "pages", - collection_id: "new-id", - collection: { - relationTo: "posts" as CollectionSlug, - value: "legacy-id", - }, - source_lng: "en", - target_lng: "de", - }, - }; - const task = normalizeJob(job); - expect(task.input.collectionSlug).toBe("pages"); - expect(task.input.collectionId).toBe("new-id"); - }); - }); - - describe("status detection", () => { - it("returns pending status for new job", () => { - const task = normalizeJob(baseJob); - expect(task.status).toBe("pending"); - }); - - it("returns running status when processing is true", () => { - const job: PayloadJob = { ...baseJob, processing: true }; - const task = normalizeJob(job); - expect(task.status).toBe("running"); - }); - - it("returns completed status when completedAt is set", () => { - const job: PayloadJob = { - ...baseJob, - completedAt: "2024-01-01T01:00:00Z", - }; - const task = normalizeJob(job); - expect(task.status).toBe("completed"); - expect(task.completedAt).toBe("2024-01-01T01:00:00Z"); - }); - - it("returns failed status once the queue has given up", () => { - const job: PayloadJob = { - ...baseJob, - error: { message: "Something went wrong" }, - hasError: true, - }; - const task = normalizeJob(job); - expect(task.status).toBe("failed"); - }); - - it("completed takes precedence over processing", () => { - const job: PayloadJob = { - ...baseJob, - completedAt: "2024-01-01T01:00:00Z", - processing: true, - }; - const task = normalizeJob(job); - expect(task.status).toBe("completed"); - }); - - it("completed takes precedence over error", () => { - const job: PayloadJob = { - ...baseJob, - completedAt: "2024-01-01T01:00:00Z", - error: { message: "err" }, - }; - const task = normalizeJob(job); - expect(task.status).toBe("completed"); - }); - }); - - describe("error handling", () => { - it("extracts error message from error object", () => { - const job: PayloadJob = { - ...baseJob, - error: { message: "Translation failed" }, - }; - const task = normalizeJob(job); - expect(task.error).toEqual({ message: "Translation failed" }); - }); - - it("returns unknown error for non-standard error", () => { - const job: PayloadJob = { ...baseJob, error: "some string error" }; - const task = normalizeJob(job); - expect(task.error).toEqual({ message: "Unknown error" }); - }); - - it("returns unknown error for error without message", () => { - const job: PayloadJob = { ...baseJob, error: { code: 500 } }; - const task = normalizeJob(job); - expect(task.error).toEqual({ message: "Unknown error" }); - }); - - it("sets error to undefined when no error", () => { - const task = normalizeJob(baseJob); - expect(task.error).toBeUndefined(); - }); - }); - - describe("cancelled detection", () => { - it("detects cancelled job", () => { - const job: PayloadJob = { - ...baseJob, - error: { cancelled: true, message: "Cancelled" }, - }; - const task = normalizeJob(job); - expect(task.cancelled).toBe(true); - }); - - it("returns false for non-cancelled error", () => { - const job: PayloadJob = { ...baseJob, error: { message: "Failed" } }; - const task = normalizeJob(job); - expect(task.cancelled).toBe(false); - }); - - it("returns false for cancelled: false", () => { - const job: PayloadJob = { - ...baseJob, - error: { cancelled: false, message: "Error" }, - }; - const task = normalizeJob(job); - expect(task.cancelled).toBe(false); - }); - - it("returns false for no error", () => { - const task = normalizeJob(baseJob); - expect(task.cancelled).toBe(false); - }); - }); - - describe("missing input fields", () => { - it("handles missing input gracefully", () => { - const job: PayloadJob = { ...baseJob, input: undefined }; - const task = normalizeJob(job); - expect(task.input).toEqual({ - collectionSlug: "", - collectionId: "", - sourceLng: "", - targetLng: "", - publishOnTranslation: false, - }); - }); - - it("handles missing collection in input", () => { - const job: PayloadJob = { - ...baseJob, - input: { source_lng: "en", target_lng: "de" }, - }; - const task = normalizeJob(job); - expect(task.input.collectionSlug).toBe(""); - expect(task.input.collectionId).toBe(""); - }); - - it("reads a run written before 1.0 as one target per listed locale", () => { - const job: PayloadJob = { - ...baseJob, - input: { ...baseJob.input, targets: undefined, target_lngs: ["de", "fr"] }, - }; - - expect(normalizeJobLocales(job).map((task) => task.input.targetLng)).toEqual(["de", "fr"]); - }); - - it("carries a pre-1.0 skip_existing run's intent over as the drift criterion", () => { - const job: PayloadJob = { - ...baseJob, - input: { - ...baseJob.input, - targets: undefined, - target_lngs: ["de"], - strategy: "skip_existing", - }, - }; - - expect(runTargets(job.input)).toEqual([ - { lng: "de", filters: [{ kind: "rule", name: "stale" }] }, - ]); - }); - }); - - describe("completedAt handling", () => { - it("sets completedAt to undefined when null", () => { - const job: PayloadJob = { ...baseJob, completedAt: null }; - const task = normalizeJob(job); - expect(task.completedAt).toBeUndefined(); - }); - - it("sets completedAt to value when provided", () => { - const job: PayloadJob = { - ...baseJob, - completedAt: "2024-01-02T00:00:00Z", - }; - const task = normalizeJob(job); - expect(task.completedAt).toBe("2024-01-02T00:00:00Z"); - }); - }); -}); - -describe("normalizeJobLocales", () => { - const workflowJob: PayloadJob = { - id: "job-999", - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:05:00Z", - input: { - collection_slug: "posts", - collection_id: "doc-456", - source_lng: "en", - targets: [ - { lng: "de", filters: [] }, - { lng: "fr", filters: [] }, - { lng: "es", filters: [] }, - ], - }, - }; - - it("expands a pre-workflow job to itself", () => { - const legacy: PayloadJob = { - id: "job-1", - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - input: { collection_slug: "posts", collection_id: "doc-1", target_lng: "de" }, - }; - const rows = normalizeJobLocales(legacy); - expect(rows).toEqual([normalizeJob(legacy)]); - }); - - it("gives every requested locale a row, in the requested order", () => { - const rows = normalizeJobLocales(workflowJob); - expect(rows.map((r) => r.input.targetLng)).toEqual(["de", "fr", "es"]); - }); - - it("does not call a locale failed while its run is still retrying", () => { - const [de] = normalizeJobLocales({ - ...workflowJob, - input: { ...workflowJob.input, targets: [{ lng: "de", filters: [] }] }, - log: [{ state: "failed", input: { target_lng: "de" } }], - } as PayloadJob); - - expect( - de?.status, - "a locale whose last attempt failed but which will be tried again is still work in flight" - ).toBe("pending"); - }); - - it("calls a locale failed once its run has given up", () => { - const [de] = normalizeJobLocales({ - ...workflowJob, - input: { ...workflowJob.input, targets: [{ lng: "de", filters: [] }] }, - error: { message: "boom" }, - hasError: true, - log: [{ state: "failed", input: { target_lng: "de" } }], - } as PayloadJob); - - expect(de?.status).toBe("failed"); - }); - - it("takes a locale's success from its own log entry, not from the job's status", () => { - const rows = normalizeJobLocales({ - ...workflowJob, - processing: true, - log: [ - { state: "succeeded", completedAt: "2024-01-01T00:01:00Z", input: { target_lng: "de" } }, - { state: "failed", completedAt: "2024-01-01T00:02:00Z", input: { target_lng: "fr" } }, - ], - }); - expect( - rows.map((r) => [r.input.targetLng, r.status]), - "de finished on its own evidence; fr's attempt failed but the run has not given up, so it is still running" - ).toEqual([ - ["de", "completed"], - ["fr", "running"], - ["es", "running"], - ]); - }); - - it("keeps a finished locale finished even once the run gives up", () => { - const rows = normalizeJobLocales({ - ...workflowJob, - error: { message: "boom" }, - hasError: true, - log: [ - { state: "succeeded", completedAt: "2024-01-01T00:01:00Z", input: { target_lng: "de" } }, - { state: "failed", completedAt: "2024-01-01T00:02:00Z", input: { target_lng: "fr" } }, - ], - }); - expect(rows.map((r) => [r.input.targetLng, r.status])).toEqual([ - ["de", "completed"], - ["fr", "failed"], - ["es", "failed"], - ]); - }); - - it("stamps completedAt only on a locale that succeeded", () => { - const rows = normalizeJobLocales({ - ...workflowJob, - log: [ - { state: "succeeded", completedAt: "2024-01-01T00:01:00Z", input: { target_lng: "de" } }, - { state: "failed", completedAt: "2024-01-01T00:02:00Z", input: { target_lng: "fr" } }, - ], - }); - expect(rows[0].completedAt).toBe("2024-01-01T00:01:00Z"); - expect(rows[1].completedAt).toBeUndefined(); - }); - - it("keeps the failure on the locale that failed, off the ones that landed", () => { - const rows = normalizeJobLocales({ - ...workflowJob, - error: { message: "provider refused fr" }, - log: [ - { state: "succeeded", completedAt: "2024-01-01T00:01:00Z", input: { target_lng: "de" } }, - { state: "failed", completedAt: "2024-01-01T00:02:00Z", input: { target_lng: "fr" } }, - ], - }); - expect(rows[0].error).toBeUndefined(); - expect(rows[1].error).toEqual({ message: "provider refused fr" }); - }); - - it("takes a retried locale's most recent log entry", () => { - const rows = normalizeJobLocales({ - ...workflowJob, - input: { ...workflowJob.input, targets: [{ lng: "de", filters: [] }] }, - log: [ - { state: "failed", completedAt: "2024-01-01T00:01:00Z", input: { target_lng: "de" } }, - { state: "succeeded", completedAt: "2024-01-01T00:03:00Z", input: { target_lng: "de" } }, - ], - }); - expect(rows[0].status).toBe("completed"); - expect(rows[0].completedAt).toBe("2024-01-01T00:03:00Z"); - }); - - it("keeps the real job id on every row, because cancelling one cancels the job", () => { - const rows = normalizeJobLocales(workflowJob); - expect(rows.map((r) => r.id)).toEqual(["job-999", "job-999", "job-999"]); - }); -}); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/normalizeJobLocales.contract.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/normalizeJobLocales.contract.test.ts deleted file mode 100644 index 234141805..000000000 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/normalizeJobLocales.contract.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { describe, it, expect } from "vitest"; - -import { normalizeJobLocales } from "../store/normalizeJob.js"; -import type { PayloadJob } from "../store/types.js"; - -type StoredInput = NonNullable; - -const inputOf = (locales: Partial): StoredInput => ({ - collection_slug: "posts", - collection_id: "doc-1", - source_lng: "en", - publish_on_translation: false, - ...locales, -}); - -const run = (overrides: Partial = {}): PayloadJob => ({ - id: 7, - createdAt: "2026-10-09T10:00:00Z", - updatedAt: "2026-10-09T10:01:00Z", - input: inputOf({ targets: [{ lng: "de", filters: [] }] }), - ...overrides, -}); - -describe("normalizeJobLocales", () => { - it("gives a run one row per entry of its locale list, in that order", () => { - const threeLocales = run({ - input: inputOf({ - targets: [ - { lng: "de", filters: [] }, - { lng: "fr", filters: [] }, - { lng: "es", filters: [] }, - ], - }), - }); - - expect(normalizeJobLocales(threeLocales).map((task) => task.input.targetLng)).toEqual([ - "de", - "fr", - "es", - ]); - }); - - it("reproduces a repeated locale as two rows", () => { - const repeated = run({ - input: inputOf({ - targets: [ - { lng: "de", filters: [] }, - { lng: "fr", filters: [] }, - { lng: "de", filters: [] }, - ], - }), - }); - - expect( - normalizeJobLocales(repeated).map((task) => task.input.targetLng), - "the caller decides whether a repeated locale is a mistake" - ).toEqual(["de", "fr", "de"]); - }); - - it("gives a job with no locale list one row for the single locale it carries", () => { - const beforeLists = run({ input: inputOf({ target_lng: "de" }) }); - - expect(normalizeJobLocales(beforeLists).map((task) => task.input.targetLng)).toEqual(["de"]); - }); - - it("gives a job with an empty locale list one row for the single locale it carries", () => { - const emptyList = run({ input: inputOf({ targets: [], target_lng: "de" }) }); - - expect(normalizeJobLocales(emptyList).map((task) => task.input.targetLng)).toEqual(["de"]); - }); - - it("completes a locale whose own latest entry succeeded, although the run failed", () => { - const succeededInsideCancelledRun = run({ - error: { message: "provider down" }, - log: [ - { state: "succeeded", completedAt: "2026-10-09T10:05:00Z", input: { target_lng: "de" } }, - ], - }); - - expect(normalizeJobLocales(succeededInsideCancelledRun).map((task) => task.status)).toEqual([ - "completed", - ]); - }); - - it("dates a completed locale by its own entry, not by the run", () => { - const runFinishedLater = run({ - completedAt: "2026-10-09T10:09:00Z", - log: [ - { state: "succeeded", completedAt: "2026-10-09T10:05:00Z", input: { target_lng: "de" } }, - ], - }); - - expect(normalizeJobLocales(runFinishedLater).map((task) => task.completedAt)).toEqual([ - "2026-10-09T10:05:00Z", - ]); - }); - - it("leaves a completed locale without an error, although the run carries one", () => { - const succeededInsideCancelledRun = run({ - error: { message: "provider down" }, - log: [ - { state: "succeeded", completedAt: "2026-10-09T10:05:00Z", input: { target_lng: "de" } }, - ], - }); - - expect(normalizeJobLocales(succeededInsideCancelledRun).map((task) => task.error)).toEqual([ - undefined, - ]); - }); - - it("leaves a completed locale uncancelled, although the run was cancelled", () => { - const succeededInsideCancelledRun = run({ - error: { cancelled: true }, - log: [ - { state: "succeeded", completedAt: "2026-10-09T10:05:00Z", input: { target_lng: "de" } }, - ], - }); - - expect(normalizeJobLocales(succeededInsideCancelledRun).map((task) => task.cancelled)).toEqual([ - false, - ]); - }); - - it("reports a locale whose latest entry failed as the run stands, not as the entry does", () => { - const stillRetrying = run({ - processing: true, - log: [{ state: "failed", input: { target_lng: "de" } }], - }); - - expect( - normalizeJobLocales(stillRetrying).map((task) => task.status), - "the queue appends a failed entry on every attempt and gives up only once" - ).toEqual(["running"]); - }); - - it("takes a locale's latest entry over its earlier attempt", () => { - const succeededOnRetry = run({ - processing: true, - log: [ - { state: "failed", input: { target_lng: "de" } }, - { state: "succeeded", completedAt: "2026-10-09T10:05:00Z", input: { target_lng: "de" } }, - ], - }); - - expect(normalizeJobLocales(succeededOnRetry).map((task) => task.status)).toEqual(["completed"]); - }); - - it("does not complete a locale whose latest entry failed after an earlier success", () => { - const failedAfterSuccess = run({ - processing: true, - log: [ - { state: "succeeded", completedAt: "2026-10-09T10:05:00Z", input: { target_lng: "de" } }, - { state: "failed", input: { target_lng: "de" } }, - ], - }); - - expect(normalizeJobLocales(failedAfterSuccess).map((task) => task.status)).toEqual(["running"]); - }); - - it("reports a locale with no entry of its own as the run stands", () => { - const anotherLocaleSettled = run({ - processing: true, - log: [ - { state: "succeeded", completedAt: "2026-10-09T10:05:00Z", input: { target_lng: "fr" } }, - ], - }); - - expect(normalizeJobLocales(anotherLocaleSettled).map((task) => task.status)).toEqual([ - "running", - ]); - }); - - it("gives every row of one run the same id", () => { - const threeLocales = run({ - input: inputOf({ - targets: [ - { lng: "de", filters: [] }, - { lng: "fr", filters: [] }, - { lng: "es", filters: [] }, - ], - }), - }); - const ids = normalizeJobLocales(threeLocales).map((task) => task.id); - - expect(new Set(ids).size).toBe(1); - }); - - it("reads a run with a completedAt as completed", () => { - const finished = run({ completedAt: "2026-10-09T10:09:00Z" }); - - expect(normalizeJobLocales(finished).map((task) => task.status)).toEqual(["completed"]); - }); - - it("reads a run that is processing as running", () => { - const inFlight = run({ processing: true }); - - expect(normalizeJobLocales(inFlight).map((task) => task.status)).toEqual(["running"]); - }); - - it("reads a run the queue has given up on as failed", () => { - const broken = run({ error: { message: "provider down" }, hasError: true }); - - expect(normalizeJobLocales(broken).map((task) => task.status)).toEqual(["failed"]); - }); - - it("reads a run that failed an attempt but will retry as pending", () => { - const retrying = run({ error: { message: "provider down" } }); - - expect( - normalizeJobLocales(retrying).map((task) => task.status), - "Payload stores an error on every attempt and sets hasError only once it stops retrying" - ).toEqual(["pending"]); - }); - - it("reads a run with no completedAt, no processing and no error as pending", () => { - expect(normalizeJobLocales(run()).map((task) => task.status)).toEqual(["pending"]); - }); - - it("reads a run as completed even though it is also processing and carries an error", () => { - const finishedAfterTrouble = run({ - completedAt: "2026-10-09T10:09:00Z", - processing: true, - error: { message: "provider down" }, - }); - - expect(normalizeJobLocales(finishedAfterTrouble).map((task) => task.status)).toEqual([ - "completed", - ]); - }); - - it("reads a run as running even though it also carries an error", () => { - const retryingAfterTrouble = run({ processing: true, error: { message: "provider down" } }); - - expect(normalizeJobLocales(retryingAfterTrouble).map((task) => task.status)).toEqual([ - "running", - ]); - }); -}); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/owedIfGaveUp.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/owedIfGaveUp.test.ts index 0501f5ef5..12ba42013 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/owedIfGaveUp.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/owedIfGaveUp.test.ts @@ -10,23 +10,16 @@ const run = (parts: Partial = {}): PayloadJob => id: 7, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", - input: { - collection_slug: "docs", - collection_id: "doc-1", - source_lng: "en", - targets: [ - { lng: "de", filters: [] }, - { lng: "fr", filters: [] }, - { lng: "es", filters: [] }, - ], - }, + input: { handle: "run-1", locales: ["de", "fr", "es"] }, ...parts, }) as PayloadJob; const tried = (locale: string, totalTried: number) => ({ [TASK]: { [locale]: { totalTried } } }); +const RUN_LOCALES = ["de", "fr", "es"]; + const locales = (job: PayloadJob, target: string, limit: number) => - owedIfGaveUp(job, TASK, target, limit).map((task) => task.input.targetLng); + owedIfGaveUp(job, TASK, target, limit, RUN_LOCALES); describe("owedIfGaveUp", () => { it("says nothing while the run still has an attempt left for the locale", () => { diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/owedOnCancel.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/owedOnCancel.test.ts index 4cf71eaba..e2b5f4e1b 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/owedOnCancel.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/owedOnCancel.test.ts @@ -7,22 +7,15 @@ const run = (overrides: Partial = {}): PayloadJob => ({ id: 7, createdAt: "2026-10-09T10:00:00Z", updatedAt: "2026-10-09T10:01:00Z", - input: { - collection_slug: "posts", - collection_id: "doc-1", - source_lng: "en", - targets: [ - { lng: "de", filters: [] }, - { lng: "fr", filters: [] }, - ], - publish_on_translation: false, - }, + input: { handle: "run-1", locales: ["de", "fr"] }, ...overrides, }); +const LOCALES = ["de", "fr"]; + describe("owedOnCancel", () => { it("owes every locale of a run that has not started", () => { - expect(owedOnCancel(run()).map((task) => task.input.targetLng)).toEqual(["de", "fr"]); + expect(owedOnCancel(run(), LOCALES)).toEqual(["de", "fr"]); }); it("owes a locale whose attempt failed while the run can still try again", () => { @@ -31,7 +24,7 @@ describe("owedOnCancel", () => { log: [{ state: "failed", input: { target_lng: "de" } }], }); - expect(owedOnCancel(betweenAttempts).map((task) => task.input.targetLng)).toEqual(["de", "fr"]); + expect(owedOnCancel(betweenAttempts, LOCALES)).toEqual(["de", "fr"]); }); it("does not owe a locale the run delivered", () => { @@ -41,7 +34,7 @@ describe("owedOnCancel", () => { ], }); - expect(owedOnCancel(halfDone).map((task) => task.input.targetLng)).toEqual(["fr"]); + expect(owedOnCancel(halfDone, LOCALES)).toEqual(["fr"]); }); it("owes nothing once the run has given up", () => { @@ -52,7 +45,7 @@ describe("owedOnCancel", () => { }); expect( - owedOnCancel(spent), + owedOnCancel(spent, LOCALES), "giving up settled every locale it had not delivered; a second ending is one too many" ).toEqual([]); }); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/planEnqueue.requester.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/planEnqueue.requester.test.ts deleted file mode 100644 index 1d8ee1dbf..000000000 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/planEnqueue.requester.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { planEnqueue } from "../model/planEnqueue.js"; -import type { RequestShape } from "../model/planEnqueue.js"; -import type { PayloadJob } from "../store/types.js"; - -const anna: RequestShape = { - collectionSlug: "posts", - collectionId: "doc-1", - sourceLng: "en", - publishOnTranslation: false, - requesterId: "anna", - requesterCollection: "users", -}; - -const jobQueuedBy = ( - requesterId: string | null, - requesterCollection: string | null = requesterId === null ? null : "users" -): PayloadJob => ({ - id: "job-1", - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - input: { - collection_slug: "posts", - collection_id: "doc-1", - source_lng: "en", - publish_on_translation: false, - targets: [{ lng: "de", filters: [] }], - requester_id: requesterId, - requester_collection: requesterCollection, - }, -}); - -// A job carries one requester for all of its locales, so extending someone else's job would run this -// request under rights its author does not have. Same argument the source-locale and strategy checks -// already make, with a sharper edge. -describe("planEnqueue — whose job may host a request", () => { - it("does not extend a job queued by someone else", () => { - const plan = planEnqueue({ - live: [jobQueuedBy("boris")], - request: anna, - requested: [{ lng: "fr", filters: [] }], - exclusiveQueue: false, - }); - - expect(plan.host).toBeNull(); - expect(plan.queue).toEqual(["fr"]); - }); - - it("extends a job the same requester queued", () => { - const plan = planEnqueue({ - live: [jobQueuedBy("anna")], - request: anna, - requested: [{ lng: "fr", filters: [] }], - exclusiveQueue: false, - }); - - expect(plan.host).not.toBeNull(); - expect(plan.append).toEqual(["fr"]); - }); - - // A job queued before this field existed records nobody. It must not become everybody's host. - it("does not extend a job that recorded no requester", () => { - const plan = planEnqueue({ - live: [jobQueuedBy(null)], - request: anna, - requested: [{ lng: "fr", filters: [] }], - exclusiveQueue: false, - }); - - expect(plan.host).toBeNull(); - expect(plan.queue).toEqual(["fr"]); - }); - - it("lets an unattributed request extend an unattributed job", () => { - const plan = planEnqueue({ - live: [jobQueuedBy(null)], - request: { ...anna, requesterId: null, requesterCollection: null }, - requested: [{ lng: "fr", filters: [] }], - exclusiveQueue: false, - }); - - expect(plan.host).not.toBeNull(); - expect(plan.append).toEqual(["fr"]); - }); - - // Two auth-enabled collections is the configuration the two-key identity exists for: `admins:1` and - // `editors:1` are different people carrying the same id. - it("does not extend a job queued by the same id in a different collection", () => { - const plan = planEnqueue({ - live: [jobQueuedBy("anna", "admins")], - request: anna, - requested: [{ lng: "fr", filters: [] }], - exclusiveQueue: false, - }); - - expect(plan.host).toBeNull(); - expect(plan.queue).toEqual(["fr"]); - }); -}); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/planEnqueue.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/planEnqueue.test.ts deleted file mode 100644 index 7ec090c5b..000000000 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/planEnqueue.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { planEnqueue } from "../model/planEnqueue.js"; -import type { EnqueuePlan } from "../model/planEnqueue.js"; -import type { RequestShape } from "../model/planEnqueue.js"; -import type { PayloadJob } from "../store/types.js"; - -const request: RequestShape = { - collectionSlug: "posts", - collectionId: "doc-1", - sourceLng: "en", - publishOnTranslation: false, - requesterId: null, - requesterCollection: null, -}; - -const storedInput = { - collection_slug: "posts", - collection_id: "doc-1", - source_lng: "en", - publish_on_translation: false, - targets: [{ lng: "de", filters: [] }], -}; - -const job = (overrides: Partial = {}): PayloadJob => ({ - id: "job-1", - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - input: storedInput, - ...overrides, -}); - -/** The cases below are about which bucket a locale lands in, so they ask for whole documents. */ -const plan = (live: PayloadJob[], locales: string[], exclusiveQueue = false) => - planEnqueue({ - live, - request, - requested: locales.map((lng) => ({ lng, filters: [] })), - exclusiveQueue, - }); - -const planOf = (over: Partial = {}): EnqueuePlan => ({ - host: null, - append: [], - queue: [], - covered: [], - coveredBy: null, - ...over, -}); - -describe("planEnqueue", () => { - it("queues everything when the document has no live job", () => { - expect(plan([], ["de", "fr"])).toEqual(planOf({ queue: ["de", "fr"] })); - }); - - it("gives a locale the job translated and then failed on a run of its own", () => { - const live = job({ - log: [ - { state: "succeeded", input: { target_lng: "de" } }, - { state: "failed", input: { target_lng: "de" } }, - ], - }); - expect( - plan([live], ["de"]), - "it was translated once, so Payload will not run that id again — it belongs in no other list" - ).toEqual(planOf({ host: live, queue: ["de"] })); - }); - - it("will not host on a job whose retries are spent", () => { - const spent = job({ hasError: true }); - expect( - plan([spent], ["de", "fr"]), - "Payload never picks that job up again, so appending to it would promise nothing" - ).toEqual(planOf({ queue: ["de", "fr"] })); - }); - - it("adds locales the live job does not carry yet", () => { - const live = job(); - expect(plan([live], ["fr", "es"])).toEqual(planOf({ host: live, append: ["fr", "es"] })); - }); - - it("reports a locale the live job already owes as covered by it", () => { - const live = job(); - expect( - plan([live], ["de"]), - "nothing is written for it, yet that job will translate it — so the caller is owed its handle" - ).toEqual(planOf({ host: live, covered: ["de"], coveredBy: live })); - }); - - it("gives a locale the live job has already translated a job of its own", () => { - const live = job({ - log: [{ state: "succeeded", input: { target_lng: "de" } }], - }); - expect(plan([live], ["de"])).toEqual(planOf({ host: live, queue: ["de"] })); - }); - - it("splits a request across both when it mixes the two", () => { - const live = job({ log: [{ state: "succeeded", input: { target_lng: "de" } }] }); - expect(plan([live], ["de", "fr"])).toEqual( - planOf({ host: live, append: ["fr"], queue: ["de"] }) - ); - }); - - it("ignores duplicates in the request", () => { - const live = job(); - expect(plan([live], ["fr", "fr"])).toEqual(planOf({ host: live, append: ["fr"] })); - }); - - it.each([ - ["oldest first", ["old", "new"]], - ["newest first", ["new", "old"]], - ])("extends the newest live job, %s", (_label, order) => { - const byId = { - old: job({ id: "old", createdAt: "2026-01-01T00:00:00Z" }), - new: job({ id: "new", createdAt: "2026-01-02T00:00:00Z" }), - }; - expect( - plan( - order.map((id) => byId[id as "old" | "new"]), - ["fr"] - ).host?.id - ).toBe("new"); - }); - - it("picks the newest job that is usable, not the newest job", () => { - const newerButCancelled = job({ - id: "cancelled", - createdAt: "2026-01-03T00:00:00Z", - error: { cancelled: true }, - }); - const usable = job({ id: "usable", createdAt: "2026-01-02T00:00:00Z" }); - expect(plan([newerButCancelled, usable], ["fr"]).host?.id).toBe("usable"); - }); - - it("does not extend a job in the pre-workflow shape", () => { - const legacy = job({ - input: { ...storedInput, targets: undefined, target_lng: "de" }, - }); - expect(plan([legacy], ["fr"])).toEqual(planOf({ queue: ["fr"] })); - }); - - it("does not extend a cancelled job", () => { - const cancelled = job({ error: { cancelled: true } }); - expect(plan([cancelled], ["fr"])).toEqual(planOf({ queue: ["fr"] })); - }); - - describe("when the host enabled Payload's concurrency control", () => { - it("queues alongside a running job instead of extending it", () => { - const running = job({ processing: true }); - expect(plan([running], ["fr"], true)).toEqual(planOf({ queue: ["fr"], coveredBy: running })); - }); - - it("merges an already-translated locale into the alongside job too", () => { - const running = job({ - processing: true, - log: [{ state: "succeeded", input: { target_lng: "de" } }], - }); - expect(plan([running], ["de", "fr"], true)).toEqual( - planOf({ queue: ["fr", "de"], coveredBy: running }) - ); - }); - - it("still extends a job that has not started", () => { - const pending = job({ processing: false }); - expect(plan([pending], ["fr"], true)).toEqual(planOf({ host: pending, append: ["fr"] })); - }); - }); - - it("extends a running job when the host has NOT enabled concurrency control", () => { - const running = job({ processing: true }); - expect(plan([running], ["fr"])).toEqual(planOf({ host: running, append: ["fr"] })); - }); - - describe("every requested locale is accounted for", () => { - const partlyTranslated = () => - job({ - input: { - ...storedInput, - targets: [ - { lng: "de", filters: [] }, - { lng: "fr", filters: [] }, - ], - }, - log: [{ state: "succeeded", input: { target_lng: "de" } }], - }); - - it.each<[string, () => PayloadJob[], string[], boolean]>([ - ["no live job", () => [], ["de", "fr"], false], - ["a live job carrying one of them", () => [job()], ["de", "fr"], false], - [ - "a live job that translated one of them", - () => [partlyTranslated()], - ["de", "fr", "es"], - false, - ], - ["a live job carrying all of them", () => [partlyTranslated()], ["fr"], false], - [ - "a running job under an exclusive queue", - () => [job({ processing: true })], - ["de", "fr"], - true, - ], - ["a cancelled job", () => [job({ error: { cancelled: true } })], ["de"], false], - ])( - "splits the request into exactly three lists, with %s", - (_label, live, requested, exclusive) => { - const result = plan(live(), requested, exclusive); - const landed = [...result.append, ...result.queue, ...result.covered]; - - expect( - [...landed].sort(), - "a locale in none of them is work nobody can report, and one in two of them is reported twice" - ).toEqual([...new Set(requested)].sort()); - expect(landed, "no locale lands in two lists").toHaveLength(new Set(landed).size); - } - ); - - it("names the run covering a locale even when that run may not be appended to", () => { - const running = job({ processing: true }); - - const result = plan([running], ["de"], true); - - expect(result.host, "an exclusive queue forbids writing to it").toBeNull(); - expect(result.coveredBy, "but it is still translating de").toBe(running); - }); - }); - - describe("a job can only take locales from a request it matches", () => { - it.each([ - ["a different source locale", { source_lng: "fr" }], - ["a different publish flag", { publish_on_translation: true }], - ])("starts its own job for %s", (_label, storedOverride) => { - const live = job({ input: { ...storedInput, ...storedOverride } }); - expect(plan([live], ["fr"])).toEqual(planOf({ queue: ["fr"] })); - }); - - it("takes a locale from a request that names different fields", () => { - const live = job({ - input: { - ...storedInput, - targets: [{ lng: "de", filters: [{ kind: "leaves", addresses: ["title"] }] }], - }, - }); - - expect(plan([live], ["fr"])).toEqual(planOf({ host: live, append: ["fr"] })); - }); - }); -}); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/readCollectionRef.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/readCollectionRef.test.ts deleted file mode 100644 index 163b114b3..000000000 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/readCollectionRef.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, it, expect } from "vitest"; -import type { CollectionSlug } from "payload"; -import { readCollectionRef } from "../store/readCollectionRef.js"; -import type { PayloadJob } from "../store/types.js"; - -describe("readCollectionRef", () => { - describe("new flat-text shape", () => { - it("reads collection_slug and collection_id from the new shape", () => { - const input: PayloadJob["input"] = { - collection_slug: "posts", - collection_id: "doc-123", - source_lng: "en", - target_lng: "de", - }; - const ref = readCollectionRef(input); - expect(ref.collectionSlug).toBe("posts"); - expect(ref.collectionId).toBe("doc-123"); - }); - }); - - describe("legacy relationship shape", () => { - it("falls back to collection.relationTo / collection.value, coercing numeric id to string", () => { - const input: PayloadJob["input"] = { - collection: { relationTo: "posts" as CollectionSlug, value: 5 }, - source_lng: "en", - target_lng: "de", - }; - const ref = readCollectionRef(input); - expect(ref.collectionSlug).toBe("posts"); - expect(ref.collectionId).toBe("5"); - }); - }); - - describe("flat fields cleared to null, legacy still populated", () => { - it("falls back to the legacy reference, as an absent field does", () => { - const input = { - collection_slug: null, - collection_id: null, - collection: { relationTo: "posts" as CollectionSlug, value: "42" }, - source_lng: "en", - target_lng: "de", - } as unknown as PayloadJob["input"]; - - const ref = readCollectionRef(input); - - expect(ref.collectionSlug, "a cleared text field reads back as null, not undefined").toBe( - "posts" - ); - expect(ref.collectionId).toBe("42"); - }); - }); - - describe("new shape present alongside legacy", () => { - it("prefers new shape over legacy when both are present", () => { - const input: PayloadJob["input"] = { - collection_slug: "pages", - collection_id: "new-id", - collection: { - relationTo: "posts" as CollectionSlug, - value: "legacy-id", - }, - source_lng: "en", - target_lng: "de", - }; - const ref = readCollectionRef(input); - expect(ref.collectionSlug).toBe("pages"); - expect(ref.collectionId).toBe("new-id"); - }); - }); - - describe("input undefined", () => { - it("returns empty strings gracefully when input is undefined", () => { - const ref = readCollectionRef(undefined); - expect(ref.collectionSlug).toBe(""); - expect(ref.collectionId).toBe(""); - }); - }); -}); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/registeredOnce.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/registeredOnce.test.ts index 73b8e30a4..725cf8121 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/registeredOnce.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/registeredOnce.test.ts @@ -7,7 +7,6 @@ import type { TaskRunnerContext } from "../../TaskRunnerProvider.interface.js"; const context: TaskRunnerContext = { handler: vi.fn(), report: vi.fn().mockResolvedValue(undefined), - collections: ["posts"], }; const configuredTwice = (): Config => { diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/requesterRoundTrip.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/requesterRoundTrip.test.ts deleted file mode 100644 index 2c71f2859..000000000 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/requesterRoundTrip.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, it, expect } from "vitest"; - -import { requesterOf } from "../PayloadJobsRunnerProvider.js"; - -/** - * The stored job row is the only thing that survives between asking for a deferred translation and - * running it, so what this reads decides whether the job runs as the person who asked or as nobody — - * and "nobody" means the write skips the permission check entirely. - */ -describe("requesterOf — reading the requester back off a job row", () => { - it.each([ - ["a string id", "anna"], - ["a numeric id", 7], - ["a zero id", 0], - ])("reads back %s unchanged", (_label, userId) => { - expect(requesterOf({ requester_id: userId, requester_collection: "users" })).toEqual({ - userId, - userCollection: "users", - }); - }); - - // A row written before the scope carried a requester has both columns absent, not null. - it("reads a row queued before this feature as nobody", () => { - expect(requesterOf({})).toBeNull(); - }); - - it("reads an explicitly unattributed row as nobody", () => { - expect(requesterOf({ requester_id: null, requester_collection: null })).toBeNull(); - }); - - // Half a row cannot name a person, and guessing would run the write as someone else. - it.each([ - ["an id with no collection", { requester_id: "anna", requester_collection: null }], - ["a collection with no id", { requester_id: null, requester_collection: "users" }], - ])("reads %s as nobody", (_label, row) => { - expect(requesterOf(row)).toBeNull(); - }); -}); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/runTargets.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/runTargets.test.ts deleted file mode 100644 index efe8551c4..000000000 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/runTargets.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { StoredRunTarget } from "../store/index.js"; -import { DRIFT_CRITERION } from "../../../provenance/Provenance.criterion.js"; -import { runLocales, runTargets } from "../store/runTargets.js"; - -describe("a run's target locales", () => { - it("reads a row written in the current shape", () => { - const targets: StoredRunTarget[] = [ - { lng: "de", filters: [{ kind: "leaves", addresses: ["title"] }] }, - { lng: "fr", filters: [] }, - ]; - - expect(runTargets({ targets })).toEqual(targets); - }); - - it("answers nothing for a row that lists no locales either way", () => { - expect(runTargets({})).toEqual([]); - }); - - describe("an entry the row carries in a shape nothing writes", () => { - it("reads a missing filter list as naming no filter", () => { - expect(runTargets({ targets: [{ lng: "de" }] })).toEqual([{ lng: "de", filters: [] }]); - }); - - it.each([ - ["a filter list that is not a list", { lng: "de", filters: "stale" }], - ["a filter of an unknown kind", { lng: "de", filters: [{ kind: "colour", name: "red" }] }], - ["no locale at all", { filters: [] }], - ["nothing", null], - ])("drops an entry carrying %s, rather than answering for it", (_label, entry) => { - expect(runTargets({ targets: [entry, { lng: "fr", filters: [] }] })).toEqual([ - { lng: "fr", filters: [] }, - ]); - }); - }); - - it("gives the locale codes on their own", () => { - expect( - runLocales({ - targets: [ - { lng: "de", filters: [] }, - { lng: "fr", filters: [] }, - ], - }) - ).toEqual(["de", "fr"]); - }); -}); - -describe("a run row written before 1.0", () => { - it("reads its flat locale list as one target each, narrowing by nothing", () => { - expect(runTargets({ target_lngs: ["de", "fr"] })).toEqual([ - { lng: "de", filters: [] }, - { lng: "fr", filters: [] }, - ]); - }); - - it("carries a skip_existing run's intent over as a rule filter", () => { - expect(runTargets({ target_lngs: ["de"], strategy: "skip_existing" })).toEqual([ - { lng: "de", filters: [{ kind: "rule", name: "stale" }] }, - ]); - }); - - it("narrows by nothing for any other strategy it carried", () => { - expect(runTargets({ target_lngs: ["de"], strategy: "overwrite" })).toEqual([ - { lng: "de", filters: [] }, - ]); - }); - - /** - * The store spells the rule name rather than importing it, so that `store/` does not depend on the - * optional provenance module. Nothing but this check keeps the two spellings equal. - */ - it("names the rule provenance answers to", () => { - const [target] = runTargets({ target_lngs: ["de"], strategy: "skip_existing" }); - - expect(target?.filters).toEqual([{ kind: "rule", name: DRIFT_CRITERION }]); - }); -}); - -/* - * Appending a locale to a pre-1.0 run writes `targets` beside the `target_lngs` the row already had - * (`PayloadJobsTaskRunner.extendJob`), so a row carrying both is a shape production reaches rather - * than one nothing writes. - */ -describe("a row carrying both shapes", () => { - const extended = { - target_lngs: ["de"], - strategy: "skip_existing", - targets: [ - { lng: "de", filters: [{ kind: "rule", name: "stale" }] }, - { lng: "fr", filters: [] }, - ], - }; - - it("answers from the current shape, so the appended locale is not lost", () => { - expect(runTargets(extended).map((target) => target.lng)).toEqual(["de", "fr"]); - }); - - it("keeps the filters the current shape carries, not the ones the old strategy implied", () => { - expect(runTargets(extended)[1]?.filters).toEqual([]); - }); -}); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/stillOwed.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/stillOwed.test.ts index ededc636d..45c2d0cd6 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/stillOwed.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/__tests__/stillOwed.test.ts @@ -1,95 +1,50 @@ import { describe, it, expect } from "vitest"; -import type { Task, TaskInput } from "../../types.js"; -import { stillOwed } from "../model/stillOwed.js"; -const RUN_ID = "run-1"; +import type { PayloadJob } from "../store/types.js"; +import { stillOwed } from "../model/stillOwed.js"; -const localeOf = (targetLng: string): TaskInput => ({ - collectionSlug: "posts", - collectionId: "doc-1", - sourceLng: "en", - targetLng, - publishOnTranslation: false, -}); +const LOCALES = ["de", "fr"]; -const delivered = (targetLng: string): Task => ({ - id: RUN_ID, - status: "completed", - input: localeOf(targetLng), +const run = (log: PayloadJob["log"] = []): PayloadJob => ({ + id: 7, createdAt: "2026-10-06T10:00:00Z", updatedAt: "2026-10-06T10:01:00Z", - completedAt: "2026-10-06T10:01:00Z", - cancelled: false, + input: { handle: "run-1", locales: LOCALES }, + log, }); -const attemptedAndFailed = (targetLng: string): Task => ({ - id: RUN_ID, - status: "failed", - input: localeOf(targetLng), - createdAt: "2026-10-06T10:00:00Z", - updatedAt: "2026-10-06T10:02:00Z", - error: { message: "provider unreachable" }, - cancelled: false, +const delivered = (targetLng: string) => ({ + state: "succeeded", + completedAt: "2026-10-06T10:01:00Z", + input: { target_lng: targetLng }, }); -const neverStarted = (targetLng: string): Task => ({ - id: RUN_ID, - status: "pending", - input: localeOf(targetLng), - createdAt: "2026-10-06T10:00:00Z", - updatedAt: "2026-10-06T10:00:00Z", - cancelled: false, +const attemptedAndFailed = (targetLng: string) => ({ + state: "failed", + input: { target_lng: targetLng }, }); -describe("stillOwed", () => { - it("owes nothing when every locale was delivered", () => { - expect(stillOwed([delivered("de"), delivered("fr")])).toEqual([]); - }); - - it("owes a locale that was attempted and failed", () => { - const failedFr = attemptedAndFailed("fr"); - - expect(stillOwed([delivered("de"), failedFr])).toContainEqual(failedFr); +describe("which locales of a run are still owed", () => { + it("owes every locale of a run that has run nothing", () => { + expect(stillOwed(run(), LOCALES)).toEqual(["de", "fr"]); }); - it("owes a locale the run was translating when it stopped", () => { - const owed = stillOwed([{ ...neverStarted("de"), status: "running" }]); - expect(owed.map((task) => task.input.targetLng)).toEqual(["de"]); + it("stops owing a locale the run delivered", () => { + expect(stillOwed(run([delivered("de")]), LOCALES)).toEqual(["fr"]); }); - it("owes a locale the run never reached", () => { - const pendingEs = neverStarted("es"); - - expect(stillOwed([delivered("de"), attemptedAndFailed("fr"), pendingEs])).toContainEqual( - pendingEs - ); - }); - - it("does not announce a delivered locale, even though the run ended badly", () => { - const deliveredDe = delivered("de"); - + it("still owes a locale whose attempt failed, because a retry may yet deliver it", () => { expect( - stillOwed([deliveredDe, attemptedAndFailed("fr"), neverStarted("es")]) - ).not.toContainEqual(deliveredDe); - }); - - it("owes each locale separately, although the run's locales share one id", () => { - expect(stillOwed([attemptedAndFailed("fr"), attemptedAndFailed("es")])).toHaveLength(2); + stillOwed(run([attemptedAndFailed("de")]), LOCALES), + "Payload logs a failure on every attempt and gives up only on the last" + ).toEqual(["de", "fr"]); }); - it("orders the owed locales the way the run would have translated them", () => { - const owed = stillOwed([neverStarted("es"), delivered("de"), attemptedAndFailed("fr")]); - - expect(owed.map((task) => task.input.targetLng)).toEqual(["es", "fr"]); - }); - - it("hands back the task itself, not a locale name or a runner's row", () => { - const failedFr = attemptedAndFailed("fr"); - - expect(stillOwed([failedFr])).toEqual([failedFr]); + it("reads the last word about a locale, not the first", () => { + expect(stillOwed(run([attemptedAndFailed("de"), delivered("de")]), LOCALES)).toEqual(["fr"]); }); - it("owes nothing for a run that covered nothing", () => { - expect(stillOwed([])).toEqual([]); + it("owes nothing once every locale is delivered", () => { + expect(stillOwed(run([delivered("de"), delivered("fr")]), LOCALES)).toEqual([]); }); }); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/legacy/__tests__/readLegacyRun.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/legacy/__tests__/readLegacyRun.test.ts new file mode 100644 index 000000000..0c4bdfb35 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/legacy/__tests__/readLegacyRun.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; + +import { readLegacyRun } from "../legacyInput.js"; + +/* + * Each fixture is a shape this plugin shipped, so a live `payload-jobs` row may still carry it. + */ + +const flat = { + collection_slug: "posts", + collection_id: "doc-1", + source_lng: "en", + targets: [{ lng: "de", filters: [] }], + publish_on_translation: false, +}; + +describe("a job row written before 1.0", () => { + it("reads the flat document reference", () => { + expect(readLegacyRun(flat)).toMatchObject({ collectionSlug: "posts", collectionId: "doc-1" }); + }); + + it("reads the relationship the plugin wrote before references became ID-agnostic", () => { + const run = readLegacyRun({ + collection: { relationTo: "posts", value: "doc-1" }, + source_lng: "en", + targets: [{ lng: "de", filters: [] }], + }); + + expect(run).toMatchObject({ collectionSlug: "posts", collectionId: "doc-1" }); + }); + + it("reads a numeric id from that relationship as the string every id is to us", () => { + const run = readLegacyRun({ + collection: { relationTo: "posts", value: 7 }, + source_lng: "en", + targets: [{ lng: "de", filters: [] }], + }); + + expect(run?.collectionId).toBe("7"); + }); + + it("reads the locale list a run carries", () => { + expect(readLegacyRun(flat)?.locales).toEqual([{ lng: "de", filters: [] }]); + }); + + it("turns the strategy a run once shared into the drift rule, which is the nearest meaning", () => { + const run = readLegacyRun({ + ...flat, + targets: undefined, + target_lngs: ["de", "fr"], + strategy: "skip_existing", + }); + + expect(run?.locales).toEqual([ + { lng: "de", filters: [{ kind: "rule", name: "stale" }] }, + { lng: "fr", filters: [{ kind: "rule", name: "stale" }] }, + ]); + }); + + it("gives a run with any other strategy no filter at all", () => { + const run = readLegacyRun({ + ...flat, + targets: undefined, + target_lngs: ["de"], + strategy: "overwrite", + }); + + expect(run?.locales).toEqual([{ lng: "de", filters: [] }]); + }); + + it("reads a job queued as a bare task, which named one locale and its filters", () => { + const run = readLegacyRun({ + collection_slug: "posts", + collection_id: "doc-1", + source_lng: "en", + target_lng: "de", + filters: [{ kind: "rule", name: "stale" }], + }); + + expect(run?.locales).toEqual([{ lng: "de", filters: [{ kind: "rule", name: "stale" }] }]); + }); + + it("drops a locale entry the parser rejects rather than reading it as naming no filter", () => { + const run = readLegacyRun({ + ...flat, + targets: [{ lng: "de", filters: [] }, { nonsense: true }], + }); + + expect( + run?.locales, + "an unreadable entry says nothing about what its locale was meant to translate" + ).toEqual([{ lng: "de", filters: [] }]); + }); + + it("reads the requester a row recorded", () => { + const run = readLegacyRun({ ...flat, requester_id: 7, requester_collection: "users" }); + + expect(run).toMatchObject({ requesterId: "7", requesterCollection: "users" }); + }); + + it("reads a row that recorded nobody as nobody", () => { + expect(readLegacyRun(flat)).toMatchObject({ + requesterId: null, + requesterCollection: null, + }); + }); + + it("places nothing when the row names no document", () => { + expect(readLegacyRun({ source_lng: "en", targets: [{ lng: "de" }] })).toBeNull(); + }); + + it("places nothing when the row names no locale", () => { + expect(readLegacyRun({ ...flat, targets: [] })).toBeNull(); + }); +}); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/legacy/adoptLegacyJob.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/legacy/adoptLegacyJob.ts new file mode 100644 index 000000000..d9a6284b2 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/legacy/adoptLegacyJob.ts @@ -0,0 +1,71 @@ +import type { CollectionSlug, Payload } from "payload"; + +import type { TaskRunnerContext } from "../../TaskRunnerProvider.interface.js"; +import { asRequester } from "../../../../shared/payload/RequestScope.shapes.js"; +import type { PayloadJob, StoredWorkflowInput } from "../store/index.js"; + +import { readLegacyRun } from "./legacyInput.js"; + +export type AdoptionDeps = { + payload: Payload; + jobsCollection: string; + report: TaskRunnerContext["report"]; +}; + +/** + * Gives a job queued before 1.0 the pointer a job carries now. + * + * Adopting twice is adopting once: it replays `queued` per locale, and recording a locale already + * recorded leaves it alone. The job's own id becomes the handle — the only name it has ever had, + * and a host may be holding it. Answers nothing when the row says too little to place. + */ +export async function adoptLegacyJob( + deps: AdoptionDeps, + job: PayloadJob +): Promise { + const run = readLegacyRun(job.input); + if (run === null) return null; + + const handle = String(job.id); + const scope = { requester: asRequester(run.requesterId, run.requesterCollection) }; + + for (const locale of run.locales) { + await deps.report( + deps.payload, + { handle, targetLng: locale.lng }, + { + state: "queued", + work: { + collectionSlug: run.collectionSlug as CollectionSlug, + collectionId: run.collectionId, + sourceLng: run.sourceLng, + filters: locale.filters, + publishOnTranslation: run.publishOnTranslation, + }, + }, + scope + ); + } + + const pointer: StoredWorkflowInput = { + handle, + locales: run.locales.map((locale) => locale.lng), + }; + + const db = deps.payload.db as { + updateOne: (args: { + collection: string; + id: string | number; + data: { input: StoredWorkflowInput }; + returning: false; + }) => Promise; + }; + await db.updateOne({ + collection: deps.jobsCollection, + id: job.id, + data: { input: pointer }, + returning: false, + }); + + return pointer; +} diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/legacy/index.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/legacy/index.ts new file mode 100644 index 000000000..2cbb1aaae --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/legacy/index.ts @@ -0,0 +1,2 @@ +export { adoptLegacyJob } from "./adoptLegacyJob.js"; +export type { AdoptionDeps } from "./adoptLegacyJob.js"; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/legacy/legacyInput.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/legacy/legacyInput.ts new file mode 100644 index 000000000..f0997f57e --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/legacy/legacyInput.ts @@ -0,0 +1,104 @@ +import { z } from "zod"; + +import { TranslationFilterSchema } from "../../../../../core/domain/selection/index.js"; + +/** + * Every shape this plugin has ever written into a job row, and the readers that turn one into the + * run it describes. + * + * Ledger: docs/DEPRECATIONS.md#jobs-input-collection-field, #jobs-per-locale-task-shape and + * #the-strategy-leaves-the-stored-run. + */ + +const LegacyCollectionSchema = z.object({ + relationTo: z.string().nullish(), + value: z.union([z.string(), z.number()]).nullish(), +}); + +const RunTargetSchema = z.object({ + lng: z.string().max(256), + filters: z.array(TranslationFilterSchema).default([]), +}); + +const LegacyInputSchema = z.object({ + collection_slug: z.string().nullish(), + collection_id: z.union([z.string(), z.number()]).nullish(), + collection: LegacyCollectionSchema.nullish(), + source_lng: z.string().nullish(), + target_lng: z.string().nullish(), + targets: z.array(z.unknown()).nullish(), + target_lngs: z.array(z.string()).nullish(), + strategy: z.string().nullish(), + filters: z.array(z.unknown()).nullish(), + publish_on_translation: z.boolean().nullish(), + requester_id: z.union([z.string(), z.number()]).nullish(), + requester_collection: z.string().nullish(), +}); + +export type LegacyInput = z.infer; + +export type LegacyLocale = { + lng: string; + filters: z.infer[]; +}; + +/** What a row written in any past shape says the run was about. */ +export type LegacyRun = { + collectionSlug: string; + collectionId: string; + sourceLng: string; + publishOnTranslation: boolean; + requesterId: string | null; + requesterCollection: string | null; + locales: LegacyLocale[]; +}; + +/** + * `skip_existing` maps to the drift rule — the nearest meaning, not the same one: an empty target + * whose source stands still is no longer filled. Any other legacy strategy becomes no filter. + */ +const DRIFT_CRITERION = "stale"; + +function localesOf(input: LegacyInput): LegacyLocale[] { + if (Array.isArray(input.targets)) { + return input.targets.flatMap((entry) => { + const parsed = RunTargetSchema.safeParse(entry); + return parsed.success ? [parsed.data] : []; + }); + } + if (Array.isArray(input.target_lngs)) { + const filters: LegacyLocale["filters"] = + input.strategy === "skip_existing" ? [{ kind: "rule", name: DRIFT_CRITERION }] : []; + return input.target_lngs.map((lng) => ({ lng, filters })); + } + if (typeof input.target_lng === "string") { + const filters = (input.filters ?? []).flatMap((entry) => { + const parsed = TranslationFilterSchema.safeParse(entry); + return parsed.success ? [parsed.data] : []; + }); + return [{ lng: input.target_lng, filters }]; + } + return []; +} + +export function readLegacyRun(input: unknown): LegacyRun | null { + const parsed = LegacyInputSchema.safeParse(input ?? {}); + if (!parsed.success) return null; + + const legacy = parsed.data; + const collectionSlug = legacy.collection_slug ?? legacy.collection?.relationTo ?? ""; + const rawId = legacy.collection_id ?? legacy.collection?.value; + const sourceLng = legacy.source_lng ?? ""; + const locales = localesOf(legacy); + if (!collectionSlug || rawId == null || !sourceLng || locales.length === 0) return null; + + return { + collectionSlug, + collectionId: String(rawId), + sourceLng, + publishOnTranslation: legacy.publish_on_translation ?? false, + requesterId: legacy.requester_id == null ? null : String(legacy.requester_id), + requesterCollection: legacy.requester_collection ?? null, + locales, + }; +} diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/alreadyCovered.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/alreadyCovered.ts deleted file mode 100644 index 0b50926fe..000000000 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/alreadyCovered.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { PayloadJob, StoredRunTarget } from "../store/index.js"; -import { covers } from "./covers.js"; -import { runTargets } from "../store/runTargets.js"; - -/** - * Which requested locales the live run will translate without being touched; the caller guarantees - * the run is one this request may join. - * - * A failed attempt still counts as covered — the run comes back to it. A locale it has already - * translated does not: Payload will not re-run a task id under the same run. - */ -export type AlreadyCovered = (run: PayloadJob, requested: StoredRunTarget[]) => string[]; - -export const alreadyCovered: AlreadyCovered = (run, requested) => { - const listed = new Map(runTargets(run.input).map((target) => [target.lng, target])); - const translated = new Set( - (run.log ?? []) - .filter((entry) => entry.state === "succeeded") - .map((entry) => entry.input?.target_lng) - ); - const asked = new Map(requested.map((ask) => [ask.lng, ask])); - return [...asked.values()] - .filter((ask) => { - const entry = listed.get(ask.lng); - return entry !== undefined && !translated.has(ask.lng) && covers(entry.filters, ask.filters); - }) - .map((ask) => ask.lng); -}; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/covers.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/covers.ts deleted file mode 100644 index 4f190b3de..000000000 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/covers.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { TranslationFilter } from "../../../../../core/domain/selection/index.js"; - -/** - * Whether a run already carrying `listed` will translate at least what `asked` wants. - * - * Deliberately conservative: a `rule` is only known to imply the same rule by name, so a `false` - * costs a second run, never lost work. - */ -export type Covers = ( - listed: readonly TranslationFilter[], - asked: readonly TranslationFilter[] -) => boolean; - -const impliedBySomeOf = (listed: TranslationFilter, asked: readonly TranslationFilter[]): boolean => - asked.some((ask) => { - if (listed.kind === "leaves") { - return ( - ask.kind === "leaves" && - ask.addresses.every((address) => listed.addresses.includes(address)) - ); - } - return ask.kind === "rule" && ask.name === listed.name; - }); - -export const covers: Covers = (listed, asked) => - listed.every((filter) => impliedBySomeOf(filter, asked)); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/deliveredLocales.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/deliveredLocales.ts index ce5738f2b..effd9ead9 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/deliveredLocales.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/deliveredLocales.ts @@ -1,4 +1,4 @@ -import { latestLogByLocale } from "../store/index.js"; +import { latestLogByLocale } from "../store/jobLog.js"; import type { PayloadJob } from "../store/index.js"; /** diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/index.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/index.ts index 8190493bc..394323278 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/index.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/index.ts @@ -1,12 +1,6 @@ -export { planEnqueue } from "./planEnqueue.js"; -export type { EnqueuePlan, RequestShape } from "./planEnqueue.js"; -export { alreadyCovered } from "./alreadyCovered.js"; export { retryLimitOf } from "./retryLimitOf.js"; export { isLastAttempt } from "./isLastAttempt.js"; export { attemptsBefore } from "./attemptsBefore.js"; -export { stillOwed } from "./stillOwed.js"; export { owedIfGaveUp } from "./owedIfGaveUp.js"; export { deliveredLocales } from "./deliveredLocales.js"; -export { localesAsTheyStand } from "./localesAsTheyStand.js"; -export type { LocaleRun } from "./localesAsTheyStand.js"; export { owedOnCancel } from "./owedOnCancel.js"; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/localesAsTheyStand.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/localesAsTheyStand.ts deleted file mode 100644 index c51d6f467..000000000 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/localesAsTheyStand.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { StoredInput } from "../store/index.js"; -import { runTargets } from "../store/index.js"; - -export type LocaleRun = { - target: string; - input: Record; -}; - -const inputSharedByEveryLocale = ({ - targets: _perLocale, - target_lngs: _legacyLocales, - strategy: _legacyStrategy, - ...shared -}: StoredInput): Record => shared; - -/** - * The run's locales, one at a time, **re-reading the list on every step**. - * - * A second request for the same document appends its locales to this row, and Payload copies the - * freshly-read row onto the same `job` object after every task — so a locale added while the run is - * in flight appears here. A list read once would drop it. - * - * A locale is yielded once. Nothing in the plugin writes it twice, but the list lives in a `json` - * column, and a run that translated one locale twice would announce it to the host twice. - * - * @yields one locale of the run, with the input its task is handed - */ -export function* localesAsTheyStand(job: { input: StoredInput }): Generator { - const walked = new Set(); - for (let index = 0; ; index++) { - const shared = inputSharedByEveryLocale(job.input); - const entry = runTargets(job.input)[index]; - if (entry === undefined) return; - if (walked.has(entry.lng)) continue; - walked.add(entry.lng); - yield { - target: entry.lng, - input: { - ...shared, - target_lng: entry.lng, - filters: entry.filters, - }, - }; - } -} diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/owedIfGaveUp.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/owedIfGaveUp.ts index 3d59ec06e..779af1a2f 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/owedIfGaveUp.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/owedIfGaveUp.ts @@ -1,12 +1,10 @@ -import { stillOwed } from "./stillOwed.js"; -import type { Task } from "../../types.js"; import { isLastAttempt } from "./isLastAttempt.js"; -import { normalizeJobLocales } from "../store/index.js"; import type { PayloadJob } from "../store/index.js"; +import { stillOwed } from "./stillOwed.js"; + /** - * Which locales a run will never deliver, given that `target` has just thrown — nothing while the - * run still has an attempt left. + * Which locales a run will never deliver, given that `target` has just thrown. * * Only the loop walking a run's locales can answer it: it stops at the locale that threw, so the * ones after it never start and nothing else ever sees them. @@ -19,10 +17,11 @@ export function owedIfGaveUp( job: PayloadJob, taskName: string, target: string, - retryLimit: number -): Task[] { + retryLimit: number, + locales: readonly string[] +): string[] { const spentOnThisLocale = isLastAttempt(job.taskStatus?.[taskName]?.[target], retryLimit); const spentOnTheRun = isLastAttempt(job, retryLimit); if (!spentOnThisLocale && !spentOnTheRun) return []; - return stillOwed(normalizeJobLocales(job)); + return stillOwed(job, locales); } diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/owedOnCancel.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/owedOnCancel.ts index 44f7249f8..7c91dcc86 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/owedOnCancel.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/owedOnCancel.ts @@ -1,8 +1,7 @@ -import type { Task } from "../../types.js"; -import { normalizeJobLocales } from "../store/index.js"; -import { stillOwed } from "./stillOwed.js"; import type { PayloadJob } from "../store/index.js"; +import { stillOwed } from "./stillOwed.js"; + /** * Which of a run's locales stopping it still owes an answer about. * @@ -10,5 +9,5 @@ import type { PayloadJob } from "../store/index.js"; * delivered (see {@link owedIfGaveUp}), and Payload leaves `hasError` set from that moment on. A run * merely waiting to try again owes its undelivered locales, logged failures among them. */ -export const owedOnCancel = (job: PayloadJob): Task[] => - job.hasError === true ? [] : stillOwed(normalizeJobLocales(job)); +export const owedOnCancel = (job: PayloadJob, locales: readonly string[]): string[] => + job.hasError === true ? [] : stillOwed(job, locales); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/planEnqueue.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/planEnqueue.ts deleted file mode 100644 index 588f66778..000000000 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/planEnqueue.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { CollectionSlug } from "payload"; - -import { alreadyCovered } from "./alreadyCovered.js"; -import { isCancelled, runLocales } from "../store/index.js"; -import type { PayloadJob, StoredRunTarget } from "../store/index.js"; - -export type RequestShape = { - collectionSlug: CollectionSlug; - collectionId: string; - sourceLng: string; - publishOnTranslation: boolean; - requesterId: string | number | null; - requesterCollection: string | null; -}; - -/** - * What to do about each locale a request asked for. **Every requested locale appears in exactly one - * of `append`, `queue` and `covered`** — one in none of them is work the caller cannot report. - * - * `queue` is for a locale the live run cannot serve: one it has already translated, or one it - * lists while meaning to translate less than this request asks for. - * `covered` needs nothing done, and `coveredBy` names the run doing it — kept apart from `host`, - * which is null when the run may not be written to although it is still translating. - */ -export type EnqueuePlan = { - host: PayloadJob | null; - append: string[]; - queue: string[]; - covered: string[]; - coveredBy: PayloadJob | null; -}; - -/** - * A job carries one source locale, publish flag and requester for all of its locales, so it can host - * only a request matching those — the invariant is per document *per requester*. Filters are stored - * per locale, so they do not bar hosting. - */ -function pickHost(live: PayloadJob[], request: RequestShape): PayloadJob | null { - const usable = live.filter( - (job) => - runLocales(job.input).length > 0 && - !isCancelled(job.error) && - job.hasError !== true && - job.input?.source_lng === request.sourceLng && - (job.input?.publish_on_translation ?? false) === request.publishOnTranslation && - (job.input?.requester_id ?? null) === request.requesterId && - (job.input?.requester_collection ?? null) === request.requesterCollection - ); - const newestFirst = usable.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)); - return newestFirst[0] ?? null; -} - -/** - * One live job per document: a later request extends that job's locale list rather than replacing it. - * - * @param live - non-completed jobs for **this document only**; the caller filters by document. - * @param exclusiveQueue - the host's `enableConcurrencyControl`; with it on, a running job is queued - * behind rather than extended. - */ -export function planEnqueue(args: { - live: PayloadJob[]; - request: RequestShape; - requested: StoredRunTarget[]; - exclusiveQueue: boolean; -}): EnqueuePlan { - const asked = new Map(args.requested.map((target) => [target.lng, target])); - const requested = [...asked.keys()]; - const host = pickHost(args.live, args.request); - if (!host) return { host: null, append: [], queue: requested, covered: [], coveredBy: null }; - - const listed = new Set(runLocales(host.input)); - const missing = requested.filter((locale) => !listed.has(locale)); - const covered = alreadyCovered(host, [...asked.values()]); - const needsItsOwnRun = requested.filter( - (locale) => listed.has(locale) && !covered.includes(locale) - ); - - if (args.exclusiveQueue && host.processing) { - return { - host: null, - append: [], - queue: [...missing, ...needsItsOwnRun], - covered, - coveredBy: host, - }; - } - return { - host, - append: missing, - queue: needsItsOwnRun, - covered, - coveredBy: covered.length ? host : null, - }; -} diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/stillOwed.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/stillOwed.ts index c893716c8..a9de6fc2f 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/stillOwed.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/model/stillOwed.ts @@ -1,6 +1,7 @@ -import type { Task } from "../../types.js"; +import { deliveredLocales } from "./deliveredLocales.js"; +import type { PayloadJob } from "../store/index.js"; -/** A run's undelivered locales. Takes one run's tasks, one per target locale. */ -export type StillOwed = (tasks: Task[]) => Task[]; - -export const stillOwed: StillOwed = (tasks) => tasks.filter((task) => task.status !== "completed"); +export const stillOwed = (job: PayloadJob, locales: readonly string[]): string[] => { + const delivered = deliveredLocales(job); + return locales.filter((locale) => !delivered.has(locale)); +}; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/__tests__/readRequester.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/__tests__/readRequester.test.ts deleted file mode 100644 index 5e19040a2..000000000 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/__tests__/readRequester.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { readRequester } from "../readRequester.js"; -import type { StoredInput } from "../types.js"; - -const stored = (fields: Partial): StoredInput => fields as StoredInput; - -describe("readRequester — who a stored job says asked for it", () => { - it("names the requester a row recorded", () => { - expect(readRequester(stored({ requester_id: "anna", requester_collection: "users" }))).toEqual({ - id: "anna", - collection: "users", - }); - }); - - it("carries a numeric id across as a string, as the rest of the plugin holds ids", () => { - expect(readRequester(stored({ requester_id: 7, requester_collection: "users" }))).toEqual({ - id: "7", - collection: "users", - }); - }); - - it("keeps a zero id, which is falsy but real", () => { - expect(readRequester(stored({ requester_id: 0, requester_collection: "users" }))).toEqual({ - id: "0", - collection: "users", - }); - }); - - it("answers nothing for a row that recorded no requester", () => { - expect(readRequester(stored({ requester_id: null, requester_collection: null }))).toBeNull(); - }); - - it("answers nothing when only half of the pair is there", () => { - expect(readRequester(stored({ requester_id: "anna", requester_collection: null }))).toBeNull(); - expect(readRequester(stored({ requester_id: null, requester_collection: "users" }))).toBeNull(); - }); - - it("answers nothing for a row with no input at all", () => { - expect(readRequester(undefined)).toBeNull(); - }); -}); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/assignmentOf.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/assignmentOf.ts deleted file mode 100644 index 5d8a09e1a..000000000 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/assignmentOf.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { EnqueueAssignment } from "../../types.js"; -import { handleOf } from "./handleOf.js"; -import { readCollectionRef } from "./readCollectionRef.js"; -import type { PayloadJob } from "./types.js"; - -/** - * One locale of a stored run — or nothing, when the row cannot describe itself. - * - * Rather than substitute a default: a guessed collection or source language would reach the host as - * fact, and an empty handle would be an assignment the contract says cannot exist. - */ -export const assignmentOf = (job: PayloadJob, targetLng: string): EnqueueAssignment | undefined => { - const { collectionSlug, collectionId } = readCollectionRef(job.input); - const sourceLng = job.input?.source_lng; - const handle = handleOf(job); - if (!(collectionSlug && sourceLng && handle)) return undefined; - - return { - collectionSlug: collectionSlug, - collectionId, - sourceLng, - targetLng, - handle, - }; -}; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/index.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/index.ts index 043b23b7a..fecfc3a13 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/index.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/index.ts @@ -1,18 +1,13 @@ -export { runLocales, runTargets } from "./runTargets.js"; export type { + JobLogEntry, PayloadJob, - StoredInput, - StoredRunTarget, - StoredWorkflowInput, StoredTaskInput, - JobLogEntry, + StoredWorkflowInput, TaskStatusByLocale, } from "./types.js"; export { TaskInputSchema, WorkflowInputSchema } from "./jobInput.schema.js"; -export { toPayloadFields, withLegacyCollection } from "./toPayloadFields.js"; +export { toPayloadFields } from "./toPayloadFields.js"; export { handleOf } from "./handleOf.js"; -export { readCollectionRef } from "./readCollectionRef.js"; -export { readRequester } from "./readRequester.js"; -export { ofCollection, ofDocuments } from "./jobsWhere.js"; -export { normalizeJobLocales, latestLogByLocale, isCancelled } from "./normalizeJob.js"; -export { assignmentOf } from "./assignmentOf.js"; +export { ofHandles, withoutAHandle } from "./jobsWhere.js"; +export { latestLogByLocale } from "./jobLog.js"; +export { pointerOf } from "./pointerOf.js"; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/jobInput.schema.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/jobInput.schema.ts index 6341aaaf7..a977bc34e 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/jobInput.schema.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/jobInput.schema.ts @@ -1,75 +1,19 @@ import { z } from "zod"; -import { TranslationFilterSchema } from "../../../../../core/domain/selection/index.js"; +/** What a job row stores as a string: long enough for a UUID handle or a locale code, and bounded + * so a malformed row cannot be unbounded. */ +const MAX_STORED_STRING = 256; /** - * What a translation run stores in its job row — the one declaration of those fields. - * - * The stored types are inferred from here and the `Field[]` Payload is registered with is generated - * from here, so a field added once reaches both. + * The locale list is the one run fact kept here: the workflow walks it, and a runner never reads the + * plugin's record. */ -export const TaskInputSchema = z.object({ - collection_slug: z.string(), - collection_id: z.string(), - source_lng: z.string().max(256), - target_lng: z.string().max(256), - filters: z.array(TranslationFilterSchema).default([]), - publish_on_translation: z.boolean().default(false), -}); - -export const RunTargetSchema = z.object({ - lng: z.string().max(256), - filters: z.array(TranslationFilterSchema).default([]), +export const WorkflowInputSchema = z.object({ + handle: z.string().max(MAX_STORED_STRING), + locales: z.array(z.string().max(MAX_STORED_STRING)), }); -/** - * One run covers a document's whole locale list, so the row names the list and each task names one - * entry of it. Derived from the task's shape rather than written out again — which is also what - * keeps the two field lists in the order Payload has always been given them. - */ -export const WorkflowInputSchema = TaskInputSchema.omit({ - target_lng: true, - filters: true, -}).extend({ - targets: z.array(RunTargetSchema), - /** - * Who asked, replayed at run time so the write is checked against their rights rather than - * nobody's. `null` keeps the old behaviour of writing with access control off. - */ - requester_id: z.union([z.string(), z.number()]).nullable(), - requester_collection: z.string().nullable(), -}); - -/** - * The document reference a job row carried before those references became ID-agnostic. - * - * @deprecated Read-only fallback. See docs/DEPRECATIONS.md#jobs-input-collection-field - */ -export const LegacyCollectionSchema = z.object({ - relationTo: z.string().nullish(), - value: z.union([z.string(), z.number()]).nullish(), -}); - -/** - * What a run row written before 1.0 carries instead of `targets`: a flat locale list and the - * strategy the whole run shared. Read, never written. - * - * @deprecated See docs/DEPRECATIONS.md#the-strategy-leaves-the-stored-run - */ -export const LegacyRunSchema = z.object({ - target_lngs: z.array(z.string()), - strategy: z.string().max(256), +export const TaskInputSchema = z.object({ + handle: z.string().max(MAX_STORED_STRING), + target_lng: z.string().max(MAX_STORED_STRING), }); - -/** - * A row's input as it comes back out of the database. - * - * Everything is optional because the column is `json` and the row may predate any field in it, and - * an old enough row carries the legacy reference instead of the flat pair. That reaches inside - * `targets` too, so its entries stay unknown here and `runTargets` is what parses them. - */ -export const StoredInputSchema = TaskInputSchema.merge(WorkflowInputSchema) - .partial() - .extend({ collection: LegacyCollectionSchema.optional() }) - .extend(LegacyRunSchema.partial().shape) - .extend({ targets: z.array(z.unknown()).optional() }); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/jobLog.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/jobLog.ts new file mode 100644 index 000000000..9128dc72e --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/jobLog.ts @@ -0,0 +1,10 @@ +import type { JobLogEntry, PayloadJob } from "./types.js"; + +export function latestLogByLocale(job: PayloadJob): Map { + const byLocale = new Map(); + for (const entry of job.log ?? []) { + const lng = entry?.input?.target_lng; + if (typeof lng === "string") byLocale.set(lng, entry); + } + return byLocale; +} diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/jobsWhere.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/jobsWhere.ts index c87e9dc8e..8a1c6f1d3 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/jobsWhere.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/jobsWhere.ts @@ -1,29 +1,12 @@ -import type { CollectionSlug, Where } from "payload"; +import type { Where } from "payload"; /** - * Conditions over a job row's document reference, which Payload stores in a `json` column and this - * plugin has written in two shapes. The second `or` branch — and the numeric id {@link asStored} - * offers for it — is the deprecated one: docs/DEPRECATIONS.md#jobs-input-collection-field + * `input.handle` is a real column, so this compares rather than traversing a `json` value — a JSON + * path here hits the drizzle SQLite bug these queries step around. */ -export const ofCollection = (collectionSlug: CollectionSlug): Where => ({ - or: [ - { "input.collection_slug": { equals: collectionSlug } }, - { "input.collection.relationTo": { equals: collectionSlug } }, - ], +export const ofHandles = (handles: readonly string[]): Where => ({ + "input.handle": { in: [...handles] }, }); -/** - * A document id reaches the store as a string, but the legacy reference was written through a - * relationship field, which stored a numeric collection's id as a **number**. Comparing one against - * the other matches nothing — on every adapter, MongoDB included — so both forms are offered and the - * store matches whichever it holds. - */ -const asStored = (documentId: string): Array => - /^\d+$/u.test(documentId) ? [documentId, Number(documentId)] : [documentId]; - -export const ofDocuments = (documentIds: readonly string[]): Where => { - const values = documentIds.flatMap(asStored); - return { - or: [{ "input.collection_id": { in: values } }, { "input.collection.value": { in: values } }], - }; -}; +/** A job queued before 1.0, which has no handle of its own until it is adopted. */ +export const withoutAHandle = (): Where => ({ "input.handle": { exists: false } }); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/normalizeJob.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/normalizeJob.ts deleted file mode 100644 index 1c15ed812..000000000 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/normalizeJob.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { z } from "zod"; -import { runLocales } from "./runTargets.js"; - -import type { Task, TaskStatus } from "../../types.js"; -import type { JobLogEntry, PayloadJob } from "./types.js"; -import { readCollectionRef } from "./readCollectionRef.js"; - -function getJobStatus(job: PayloadJob): TaskStatus { - if (job.completedAt) return "completed"; - if (job.processing) return "running"; - if (job.hasError) return "failed"; - return "pending"; -} - -const ErrorWithMessage = z.object({ message: z.string() }); -const CancelledError = z.object({ cancelled: z.literal(true) }); - -function extractErrorMessage(error: unknown): string { - return ErrorWithMessage.safeParse(error).data?.message ?? "Unknown error"; -} - -export function isCancelled(error: unknown): boolean { - return CancelledError.safeParse(error).success; -} - -export function normalizeJob(job: PayloadJob): Task { - const { collectionSlug, collectionId } = readCollectionRef(job.input); - - return { - id: String(job.id), - status: getJobStatus(job), - input: { - collectionSlug, - collectionId, - sourceLng: job.input?.source_lng ?? "", - targetLng: job.input?.target_lng ?? "", - publishOnTranslation: job.input?.publish_on_translation ?? false, - }, - createdAt: job.createdAt, - updatedAt: job.updatedAt, - completedAt: job.completedAt ?? undefined, - error: job.error ? { message: extractErrorMessage(job.error) } : undefined, - cancelled: isCancelled(job.error), - }; -} - -/** - * Each locale's most recent log entry: Payload appends to `log` chronologically, so last-write-wins - * leaves the latest attempt. - */ -export function latestLogByLocale(job: PayloadJob): Map { - const byLocale = new Map(); - for (const entry of job.log ?? []) { - const lng = entry?.input?.target_lng; - if (typeof lng === "string") byLocale.set(lng, entry); - } - return byLocale; -} - -/** - * One {@link Task} per target locale of the run; a job that lists none expands to itself. - * - * A locale is `completed` only on its own succeeded log entry; every other locale takes the run's - * status, because Payload appends a `failed` entry on every attempt and marks the job only once it - * gives up (`queues/errors/handleTaskError.js`). - */ -export function normalizeJobLocales(job: PayloadJob): Task[] { - const targets = runLocales(job.input); - if (targets.length === 0) return [normalizeJob(job)]; - - const base = normalizeJob(job); - const latestByLocale = latestLogByLocale(job); - - return targets.map((targetLng) => { - const entry = latestByLocale.get(targetLng); - if (!entry) return { ...base, input: { ...base.input, targetLng } }; - const succeeded = entry.state === "succeeded"; - return { - ...base, - status: succeeded ? "completed" : base.status, - completedAt: succeeded ? (entry.completedAt ?? undefined) : undefined, - error: succeeded ? undefined : base.error, - cancelled: succeeded ? false : base.cancelled, - input: { ...base.input, targetLng }, - }; - }); -} diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/pointerOf.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/pointerOf.ts new file mode 100644 index 000000000..1e92631f4 --- /dev/null +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/pointerOf.ts @@ -0,0 +1,10 @@ +import { WorkflowInputSchema } from "./jobInput.schema.js"; +import type { PayloadJob, StoredWorkflowInput } from "./types.js"; + +/** + * The pointer a job row carries, or nothing — which is how a row queued before 1.0 announces itself. + */ +export function pointerOf(job: PayloadJob): StoredWorkflowInput | null { + const parsed = WorkflowInputSchema.safeParse(job.input); + return parsed.success ? parsed.data : null; +} diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/readCollectionRef.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/readCollectionRef.ts deleted file mode 100644 index 9371ea334..000000000 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/readCollectionRef.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { CollectionSlug } from "payload"; - -import { z } from "zod"; - -import type { ID } from "../../types.js"; -import { StoredInputSchema } from "./jobInput.schema.js"; -import type { StoredCollectionFields } from "./types.js"; - -export type CollectionRef = { - collectionSlug: CollectionSlug; - collectionId: ID; -}; - -/** - * A text field cleared through Payload's admin reads back as `null`, not as missing. For this row - * the two mean the same thing — there is no reference here, look at the legacy one. - */ -const clearedIsAbsent = (row: unknown): unknown => - row && typeof row === "object" - ? Object.fromEntries(Object.entries(row).filter(([, value]) => value !== null)) - : row; - -const CollectionRefSchema = z.preprocess( - clearedIsAbsent, - StoredInputSchema.pick({ - collection_slug: true, - collection_id: true, - collection: true, - }).transform( - (input): CollectionRef => ({ - collectionSlug: (input.collection_slug ?? - input.collection?.relationTo ?? - "") as CollectionSlug, - collectionId: String(input.collection_id ?? input.collection?.value ?? ""), - }) - ) -); - -const NOTHING: CollectionRef = { collectionSlug: "" as CollectionSlug, collectionId: "" }; - -/** - * The single storage-read boundary for a job's collection reference, and the one place allowed to - * read the legacy relationship a row carried before those references became ID-agnostic. - * See docs/DEPRECATIONS.md#jobs-input-collection-field - */ -export function readCollectionRef(input: StoredCollectionFields | undefined): CollectionRef { - const read = CollectionRefSchema.safeParse(input ?? {}); - return read.success ? read.data : NOTHING; -} diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/readRequester.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/readRequester.ts deleted file mode 100644 index cdc9a5c5d..000000000 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/readRequester.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { StoredInput } from "./types.js"; - -/** The requester a stored job recorded, or `null` when it recorded none. */ -export function readRequester( - input: StoredInput | undefined -): { id: string; collection: string } | null { - const id = input?.requester_id; - const collection = input?.requester_collection; - if (id === null || id === undefined || !collection) return null; - return { id: String(id), collection }; -} diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/runTargets.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/runTargets.ts deleted file mode 100644 index d0f4a7f77..000000000 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/runTargets.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { RunTargetSchema } from "./jobInput.schema.js"; -import type { StoredInput, StoredRunTarget } from "./types.js"; - -/** - * Spelled, not imported: `store/` must not depend on the optional provenance module. Must equal its - * `DRIFT_CRITERION` — see docs/DEPRECATIONS.md#the-strategy-leaves-the-stored-run. - */ -const DRIFT_CRITERION = "stale"; - -/** - * `skip_existing` becomes the drift rule — the nearest meaning, not the same one: an empty target - * whose source stands still is no longer filled. Anything else becomes no filter at all. - * - * @deprecated See docs/DEPRECATIONS.md#the-strategy-leaves-the-stored-run - */ -const legacyRunTargets = (input: StoredInput): StoredRunTarget[] => { - if (!Array.isArray(input.target_lngs)) return []; - - const filters: StoredRunTarget["filters"] = - input.strategy === "skip_existing" ? [{ kind: "rule", name: DRIFT_CRITERION }] : []; - return input.target_lngs.map((lng) => ({ lng, filters })); -}; - -/** - * A run's target locales, reading a row written in either shape. - * - * An entry the parser rejects is dropped rather than read as naming no filter: an unreadable entry - * says nothing about what its locale was meant to translate, and reading it as `[]` would turn a - * narrowed run into one that overwrites the whole target document. - */ -export function runTargets(input: StoredInput | undefined): StoredRunTarget[] { - if (Array.isArray(input?.targets)) { - return input.targets.flatMap((entry) => { - const parsed = RunTargetSchema.safeParse(entry); - return parsed.success ? [parsed.data] : []; - }); - } - return input ? legacyRunTargets(input) : []; -} - -export const runLocales = (input: StoredInput | undefined): string[] => - runTargets(input).map((target) => target.lng); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/toPayloadFields.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/toPayloadFields.ts index fc70734f5..406bc98b5 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/toPayloadFields.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/toPayloadFields.ts @@ -48,27 +48,3 @@ const fieldFor = (name: string, schema: ZodTypeAny): Field => { */ export const toPayloadFields = (schema: ZodObject): Field[] => Object.entries(schema.shape).map(([name, member]) => fieldFor(name, member as ZodTypeAny)); - -/** - * Splice the deprecated relationship back in where it has always sat — directly after the flat pair - * that replaced it. - * - * It is written by hand rather than generated because it is the one field with no counterpart in - * the plugin's own types: nothing writes it, one reader falls back to it, and it leaves with the - * next major. See docs/DEPRECATIONS.md#jobs-input-collection-field - */ -export const withLegacyCollection = (fields: Field[], relationTo: string[]): Field[] => { - const legacy = { - type: "relationship", - name: "collection", - relationTo, - required: false, - admin: { - readOnly: true, - description: "Deprecated. See docs/DEPRECATIONS.md#jobs-input-collection-field", - }, - } as unknown as Field; - - const after = fields.findIndex((field) => "name" in field && field.name === "collection_id"); - return [...fields.slice(0, after + 1), legacy, ...fields.slice(after + 1)]; -}; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/types.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/types.ts index afb846faa..11d18bc8b 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/types.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/payload-jobs-runner/store/types.ts @@ -1,17 +1,30 @@ import type { z } from "zod"; -import type { RunTargetSchema, StoredInputSchema, WorkflowInputSchema } from "./jobInput.schema.js"; +import type { TaskInputSchema, WorkflowInputSchema } from "./jobInput.schema.js"; type TaskSlug = string; /** Payload keys each task of a run by the id it ran under, which for this plugin is the locale. */ export type LocaleCode = string; -export type StoredInput = z.infer; - -export type StoredRunTarget = z.infer; +export type StoredWorkflowInput = z.infer; +export type StoredTaskInput = z.infer; export type TaskStatusByLocale = Record>; +export type JobLogEntry = { + taskSlug?: string; + taskID?: string; + state?: string; + input?: Record; + error?: unknown; + executedAt?: string; + completedAt?: string; +}; + +/** + * A `payload-jobs` row as this plugin reads it. `input` is left open because a row queued before 1.0 + * carries the whole run where a new one carries a pointer; only `legacy/` looks at the difference. + */ export type PayloadJob = { /** Payload sets this once a run's retries are spent, and never clears it. */ hasError?: boolean; @@ -25,26 +38,5 @@ export type PayloadJob = { error?: unknown; processing?: boolean | null; waitUntil?: string | null; - input?: StoredInput; -}; - -export type StoredCollectionFields = Pick< - z.infer, - "collection_slug" | "collection_id" | "collection" ->; - -/** - * A row read back, narrowed to the one locale this attempt translates: `targets` is the run's list, - * `target_lng` the entry this task was handed. - */ -export type StoredTaskInput = z.infer & - Required, "source_lng" | "target_lng">>; - -export type StoredWorkflowInput = z.infer; - -/** Written by Payload only once a task settles — an absent entry means that locale has not run. */ -export type JobLogEntry = { - state: "succeeded" | "failed"; - completedAt?: string | null; - input?: { target_lng?: string }; + input?: Record; }; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/SyncRunnerProvider.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/SyncRunnerProvider.ts index cc5d618f7..c7cc10f11 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/SyncRunnerProvider.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/SyncRunnerProvider.ts @@ -1,38 +1,19 @@ import type { Config, Payload } from "payload"; import type { TaskRunner } from "../TaskRunner.interface.js"; +import type { RunnerCapabilities } from "../RunnerCapabilities.js"; import type { TaskRunnerContext, TaskRunnerProvider } from "../TaskRunnerProvider.interface.js"; -import type { Task } from "../types.js"; -import type { SyncRunnerOptions } from "./types.js"; import { SyncTaskRunner } from "./SyncTaskRunner.js"; -import { LazyMap } from "../../../shared/utils/index.js"; -const DEFAULT_MAX_SIZE = 100; -const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1 hour +const INLINE_LEAVES_NOTHING_TO_REACH: RunnerCapabilities = { stop: false, run: false }; /** - * Synchronous TaskRunnerProvider implementation. - * - * Executes translations immediately without Payload Jobs. - * Useful for development, testing, or simple use cases. */ export class SyncRunnerProvider implements TaskRunnerProvider { - private readonly tasks: LazyMap; - - constructor(options?: SyncRunnerOptions) { - const maxSize = options?.maxSize ?? DEFAULT_MAX_SIZE; - const ttlMs = options?.ttlMs ?? DEFAULT_TTL_MS; - - this.tasks = new LazyMap({ - maxSize, - ttlMs, - isRemovable: (task) => task.status === "completed" || task.status === "failed", - getTimestamp: (task) => new Date(task.updatedAt).getTime(), - }); - } + readonly capabilities = INLINE_LEAVES_NOTHING_TO_REACH; create(payload: Payload, context: TaskRunnerContext): TaskRunner { - return new SyncTaskRunner(payload, context, this.tasks); + return new SyncTaskRunner(payload, context); } configure(): (config: Config) => Config { @@ -41,20 +22,22 @@ export class SyncRunnerProvider implements TaskRunnerProvider { } /** - * Creates a synchronous task runner: translations execute inline on enqueue, - * with no Payload Jobs queue. Status is kept in an in-memory map (bounded by - * `maxSize`/`ttlMs`) and is **lost on server restart**, so this is best for - * development, tests, or simple single-process setups — not serverless. Pass the + * Creates a synchronous task runner: translations run inline on enqueue, with no Payload Jobs + * queue — for development, tests and single-process hosts, not serverless. * result as `translatorPlugin({ runner })`. * - * @param options - In-memory store bounds: `maxSize` (default 100) and `ttlMs` - * (default 1h) for completed/failed task records. + * The cancel and run-now routes are **not registered** with this runner, and the admin draws no + * control for them: a translation is already over by the time the request that asked for it answers. + * What became of each run is in the plugin's own record, so status survives a restart. + * * @returns A {@link TaskRunnerProvider} for the plugin's `runner` option. + * @since 1.0.0 — the `maxSize` / `ttlMs` options are gone; see + * docs/DEPRECATIONS.md#the-runner-only-accepts-work. * @example * ```ts * translatorPlugin({ collections, translationProvider, runner: createSyncRunner() }) * ``` */ -export function createSyncRunner(options?: SyncRunnerOptions): TaskRunnerProvider { - return new SyncRunnerProvider(options); +export function createSyncRunner(): TaskRunnerProvider { + return new SyncRunnerProvider(); } diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/SyncTaskRunner.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/SyncTaskRunner.ts index bc931881f..25a722f50 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/SyncTaskRunner.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/SyncTaskRunner.ts @@ -1,150 +1,76 @@ -import type { Payload, CollectionSlug } from "payload"; +import type { Payload } from "payload"; -import type { TaskFilter, TaskRunner } from "../TaskRunner.interface.js"; -import { toTaskFilter } from "../toTaskFilter.js"; +import type { TaskRunner } from "../TaskRunner.interface.js"; import type { TaskRunnerContext } from "../TaskRunnerProvider.interface.js"; -import type { - EnqueueAssignment, - JobAssignment, - JobHandle, - Task, - TaskInput, - RunResult, - ID, -} from "../types.js"; -import type { LazyMap } from "../../../shared/utils/index.js"; +import type { EnqueueAssignment, TaskInput } from "../types.js"; import type { RequestScope } from "../../../shared/payload/RequestScope.shapes.js"; import { swallowOrThrow } from "../../../shared/payload/swallowOrThrow.js"; const NEVER_RETRIED = 0; -/** Runs each task inline on enqueue; results live only in memory, so status queries see nothing from a previous process. */ +/** + * Runs each task inline on `enqueue`, so a translation is finished by the time the call resolves. + */ export class SyncTaskRunner implements TaskRunner { private readonly payload: Payload; private readonly context: TaskRunnerContext; - private readonly tasks: LazyMap; - constructor(payload: Payload, context: TaskRunnerContext, tasks: LazyMap) { + constructor(payload: Payload, context: TaskRunnerContext) { this.payload = payload; this.context = context; - this.tasks = tasks; } async enqueue(inputs: TaskInput[], scope: RequestScope = {}): Promise { const assigned: EnqueueAssignment[] = []; const served = new Set(); + for (const input of inputs) { - const key = this.getKey(input.collectionSlug, input.collectionId, input.targetLng); + const key = `${input.collectionSlug}:${input.collectionId}:${input.targetLng}`; if (served.has(key)) continue; served.add(key); - const now = new Date().toISOString(); - - const task: Task = { - id: crypto.randomUUID(), - status: "running", - input, - createdAt: now, - updatedAt: now, - cancelled: false, - }; - this.tasks.set(key, task); + const handle = crypto.randomUUID(); const assignment: EnqueueAssignment = { collectionSlug: input.collectionSlug, collectionId: input.collectionId, sourceLng: input.sourceLng, targetLng: input.targetLng, - handle: task.id, + handle, }; assigned.push(assignment); - await this.context.report(this.payload, assignment, { state: "queued" }); - - const markEvictable = (status: "completed" | "failed", error?: Task["error"]) => { - const at = new Date().toISOString(); - task.status = status; - task.updatedAt = at; - if (status === "completed") task.completedAt = at; - if (error) task.error = error; - }; + const run = { handle, targetLng: input.targetLng }; + await this.context.report( + this.payload, + run, + { + state: "queued", + work: { + collectionSlug: input.collectionSlug, + collectionId: input.collectionId, + sourceLng: input.sourceLng, + filters: input.filters, + publishOnTranslation: input.publishOnTranslation, + }, + }, + scope + ); await swallowOrThrow( scope, async () => { await this.context.handler( this.payload, - { - collection: input.collectionSlug, - collectionId: input.collectionId, - sourceLng: input.sourceLng, - targetLng: input.targetLng, - filters: input.filters, - publishOnTranslation: input.publishOnTranslation, - previousAttempts: NEVER_RETRIED, - }, + { handle, targetLng: input.targetLng, previousAttempts: NEVER_RETRIED }, scope ); - markEvictable("completed"); - await this.context.report(this.payload, assignment, { state: "delivered" }); + await this.context.report(this.payload, run, { state: "delivered" }, scope); }, async (error) => { - markEvictable("failed", { - message: error instanceof Error ? error.message : "Unknown error", - }); - await this.context.report(this.payload, assignment, { state: "failed", error }); + await this.context.report(this.payload, run, { state: "failed", error }, scope); } ); } - return assigned; - } - - /** No-op, and reports nothing: every assignment settled inside `enqueue`, so none is still owed. */ - cancel(_taskIds: string[]): Promise { - return Promise.resolve(); - } - - async assignmentsOf(handles: readonly JobHandle[]): Promise { - const wanted = new Set(handles); - const found: JobAssignment[] = []; - - for (const [, task] of this.tasks) { - if (!wanted.has(task.id)) continue; - found.push({ - handle: task.id, - collectionSlug: task.input.collectionSlug, - collectionId: task.input.collectionId, - startedBy: null, - }); - } - - return found; - } - - async run(_taskId: string): Promise { - return { success: false, error: "not_found" }; - } - - async findByCollection( - collectionSlug: CollectionSlug, - filter?: Array | TaskFilter - ): Promise { - const { documentIds, excludeCompleted, excludeGivenUpBefore } = toTaskFilter(filter); - const results: Task[] = []; - const wanted = documentIds ? new Set(documentIds.map(String)) : undefined; - - for (const [, task] of this.tasks) { - if (task.input.collectionSlug !== collectionSlug) continue; - if (wanted && !wanted.has(task.input.collectionId)) continue; - if (excludeCompleted && task.completedAt) continue; - if (excludeGivenUpBefore && task.status === "failed") { - if (Date.parse(task.updatedAt) < excludeGivenUpBefore.getTime()) continue; - } - results.push(task); - } - return results; - } - - private getKey(collectionSlug: CollectionSlug, collectionId: ID, targetLng: string): string { - return `${collectionSlug}:${collectionId}:${targetLng}`; + return assigned; } } diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncRunnerProvider.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncRunnerProvider.test.ts index 81c3b4c90..3025267bf 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncRunnerProvider.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncRunnerProvider.test.ts @@ -11,7 +11,6 @@ const fakePayload = {} as Payload; const makeContext = (handler: TaskRunnerContext["handler"]): TaskRunnerContext => ({ handler, report: vi.fn().mockResolvedValue(undefined), - collections: ["pages" as CollectionSlug], }); const sampleInput: TaskInput = { @@ -41,13 +40,7 @@ describe("SyncRunnerProvider", () => { expect(handler).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledWith( fakePayload, - expect.objectContaining({ - collection: "pages", - collectionId: "1", - sourceLng: "en", - targetLng: "de", - publishOnTranslation: false, - }), + { handle: expect.any(String), targetLng: "de", previousAttempts: 0 }, {} ); }); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.contract.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.contract.test.ts index 890818132..d0b49545e 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.contract.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.contract.test.ts @@ -2,16 +2,16 @@ import type { Payload } from "payload"; import { vi } from "vitest"; import { assertTaskRunnerContract } from "../../../../../testing/index.js"; -import type { Task } from "../../types.js"; -import { LazyMap } from "../../../../shared/utils/index.js"; -import { SyncTaskRunner } from "../SyncTaskRunner.js"; +import { SyncRunnerProvider } from "../SyncRunnerProvider.js"; + +const provider = new SyncRunnerProvider(); assertTaskRunnerContract( "SyncTaskRunner", (report) => - new SyncTaskRunner( - {} as Payload, - { handler: vi.fn().mockResolvedValue(undefined), collections: [], report }, - new LazyMap({ isRemovable: () => false, getTimestamp: () => 0 }) - ) + provider.create({} as Payload, { + handler: vi.fn().mockResolvedValue(undefined), + report, + }), + provider.capabilities ); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.test.ts index 205694ce8..a553140c1 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.test.ts @@ -1,32 +1,39 @@ import { APIError } from "payload"; -import { TransportError } from "../../../../../translation-providers/shared/errors/index.js"; import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Payload, CollectionSlug } from "payload"; + +import { TransportError } from "../../../../../translation-providers/shared/errors/index.js"; import { SyncTaskRunner } from "../SyncTaskRunner.js"; import type { TaskHandler } from "../../TaskRunnerProvider.interface.js"; -import type { TaskInput, Task } from "../../types.js"; -import { LazyMap } from "../../../../shared/utils/index.js"; +import type { RunRef, TaskEvent, TaskInput } from "../../types.js"; -const contextOf = (handler: TaskHandler = vi.fn().mockResolvedValue(undefined)) => ({ - handler, - collections: [], - report: vi.fn().mockResolvedValue(undefined), -}); +type Reported = { run: RunRef; event: TaskEvent }; describe("SyncTaskRunner", () => { let mockPayload: Payload; let mockHandler: TaskHandler; - let tasks: LazyMap; + let reported: Reported[]; let runner: SyncTaskRunner; + const contextOf = (handler: TaskHandler) => ({ + handler, + report: vi.fn(async (_payload: Payload, run: RunRef, event: TaskEvent) => { + reported.push({ run, event }); + }), + }); + + const using = (handler: TaskHandler) => { + mockHandler = handler; + runner = new SyncTaskRunner(mockPayload, contextOf(handler)); + }; + + const statesFor = (targetLng: string): string[] => + reported.filter((entry) => entry.run.targetLng === targetLng).map((entry) => entry.event.state); + beforeEach(() => { mockPayload = {} as Payload; - mockHandler = vi.fn().mockResolvedValue(undefined); - tasks = new LazyMap({ - isRemovable: (task) => task.status === "completed" || task.status === "failed", - getTimestamp: (task) => new Date(task.updatedAt).getTime(), - }); - runner = new SyncTaskRunner(mockPayload, contextOf(mockHandler), tasks); + reported = []; + using(vi.fn().mockResolvedValue(undefined)); }); const createInput = (overrides: Partial = {}): TaskInput => ({ @@ -38,37 +45,6 @@ describe("SyncTaskRunner", () => { ...overrides, }); - describe("excludeGivenUpBefore", () => { - const givenUp = async (): Promise => { - mockHandler = vi.fn().mockRejectedValue(new Error("provider down")); - runner = new SyncTaskRunner(mockPayload, contextOf(mockHandler), tasks); - await runner.enqueue([createInput()]); - }; - - it("drops a run that gave up before the cutoff", async () => { - await givenUp(); - - const kept = await runner.findByCollection("posts" as CollectionSlug, { - excludeGivenUpBefore: new Date(Date.now() + 1000), - }); - - expect(kept, "a failure older than the window is not an action to take").toEqual([]); - }); - - it("keeps a run that gave up after the cutoff", async () => { - await givenUp(); - - const kept = await runner.findByCollection("posts" as CollectionSlug, { - excludeGivenUpBefore: new Date(Date.now() - 1000), - }); - - expect( - kept.map((task) => task.status), - "a recent failure is still worth reporting" - ).toEqual(["failed"]); - }); - }); - describe("enqueue", () => { it("executes handler immediately", async () => { const input = createInput(); @@ -76,78 +52,46 @@ describe("SyncTaskRunner", () => { expect(mockHandler).toHaveBeenCalledWith( mockPayload, - { - collection: "posts", - collectionId: "doc-123", - sourceLng: "en", - targetLng: "de", - publishOnTranslation: false, - previousAttempts: 0, - }, + { handle: expect.any(String), targetLng: "de", previousAttempts: 0 }, {} ); }); - it("ignores waitUntil and runs immediately (dev runner — no debounce)", async () => { - const input = createInput({ waitUntil: new Date("2999-01-01T00:00:00Z") }); - await runner.enqueue([input]); + it("ignores waitUntil and runs immediately — there is no queue to wait in", async () => { + await runner.enqueue([createInput({ waitUntil: new Date("2999-01-01T00:00:00Z") })]); - // Executed now despite the far-future waitUntil; task is completed synchronously. expect(mockHandler).toHaveBeenCalledTimes(1); - expect(tasks.get("posts:doc-123:de")?.status).toBe("completed"); + expect(statesFor("de")).toEqual(["queued", "delivered"]); }); - it("stores completed task in tasks map", async () => { - const input = createInput(); - await runner.enqueue([input]); + it("reports a locale failed when the handler throws one of ours", async () => { + using(vi.fn().mockRejectedValue(new TransportError("Translation failed"))); - const task = tasks.get("posts:doc-123:de"); - expect(task).toBeDefined(); - expect(task?.status).toBe("completed"); - expect(task?.completedAt).toBeDefined(); - }); + await runner.enqueue([createInput()]); - it("stores a failed task when the handler throws one of ours", async () => { - mockHandler = vi.fn().mockRejectedValue(new TransportError("Translation failed")); - runner = new SyncTaskRunner(mockPayload, contextOf(mockHandler), tasks); + expect(statesFor("de")).toEqual(["queued", "failed"]); + }); - const input = createInput(); - await runner.enqueue([input]); + it("answers one assignment per requested locale", async () => { + const answer = await runner.enqueue([ + createInput({ targetLng: "de" }), + createInput({ targetLng: "fr" }), + ]); - const task = tasks.get("posts:doc-123:de"); - expect(task?.status).toBe("failed"); - expect(task?.error?.message).toBe("Translation failed"); + expect(answer.map((entry) => entry.targetLng)).toEqual(["de", "fr"]); + expect(new Set(answer.map((entry) => entry.handle)).size, "each run its own name").toBe(2); }); - it.each([ - ["a task that succeeded", undefined], - ["a task that failed", new TransportError("Translation failed")], - ])("%s reports when it finished, not when it started", async (_label, rejection) => { - vi.useFakeTimers(); - try { - vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); - mockHandler = vi.fn().mockImplementation(async () => { - vi.setSystemTime(new Date("2026-01-01T00:05:00.000Z")); - if (rejection) throw rejection; - }); - runner = new SyncTaskRunner(mockPayload, contextOf(mockHandler), tasks); - - await runner.enqueue([createInput()]); - - const task = [...tasks.values()][0]; - expect(task?.createdAt).toBe("2026-01-01T00:00:00.000Z"); - expect(task?.updatedAt, "a settled task must carry the time it settled").toBe( - "2026-01-01T00:05:00.000Z" - ); - } finally { - vi.useRealTimers(); - } + it("serves a locale once although the request listed it twice", async () => { + const answer = await runner.enqueue([createInput(), createInput()]); + + expect(answer).toHaveLength(1); + expect(mockHandler).toHaveBeenCalledTimes(1); }); it("finishes the batch for a caller with no transaction to lose", async () => { const fromPayload = new APIError("Validation failed", 400); - mockHandler = vi.fn().mockRejectedValueOnce(fromPayload).mockResolvedValueOnce(undefined); - runner = new SyncTaskRunner(mockPayload, contextOf(mockHandler), tasks); + using(vi.fn().mockRejectedValueOnce(fromPayload).mockResolvedValueOnce(undefined)); await expect( runner.enqueue([createInput({ targetLng: "de" }), createInput({ targetLng: "fr" })]), @@ -155,26 +99,24 @@ describe("SyncTaskRunner", () => { ).resolves.toHaveLength(2); expect(mockHandler).toHaveBeenCalledTimes(2); - expect(tasks.get("posts:doc-123:de")?.status).toBe("failed"); - expect(tasks.get("posts:doc-123:fr")?.status).toBe("completed"); + expect(statesFor("de")).toEqual(["queued", "failed"]); + expect(statesFor("fr")).toEqual(["queued", "delivered"]); }); it("swallows one of ours even inside the caller's transaction", async () => { - mockHandler = vi.fn().mockRejectedValue(new TransportError("provider down")); - runner = new SyncTaskRunner(mockPayload, contextOf(mockHandler), tasks); + using(vi.fn().mockRejectedValue(new TransportError("provider down"))); await expect( runner.enqueue([createInput()], { transactionID: "tx-1" }), "a provider failure ran no Payload operation, so the editor's save is still intact" ).resolves.toHaveLength(1); - expect(tasks.get("posts:doc-123:de")?.status).toBe("failed"); + expect(statesFor("de")).toEqual(["queued", "failed"]); }); it("lets a failure that did not come from the translator out", async () => { const fromPayload = new APIError("Validation failed", 400); - mockHandler = vi.fn().mockRejectedValue(fromPayload); - runner = new SyncTaskRunner(mockPayload, contextOf(mockHandler), tasks); + using(vi.fn().mockRejectedValue(fromPayload)); await expect( runner.enqueue([createInput()], { transactionID: "tx-1" }), @@ -182,152 +124,32 @@ describe("SyncTaskRunner", () => { ).rejects.toBe(fromPayload); }); - it("records a non-Error throw and still lets it out", async () => { - mockHandler = vi.fn().mockRejectedValue("string error"); - runner = new SyncTaskRunner(mockPayload, contextOf(mockHandler), tasks); + it("reports the failure of a non-Error throw and still lets it out", async () => { + using(vi.fn().mockRejectedValue("string error")); - const input = createInput(); - await expect(runner.enqueue([input], { transactionID: "tx-1" })).rejects.toBe("string error"); - - const task = tasks.get("posts:doc-123:de"); - expect(task?.status).toBe("failed"); - expect(task?.error?.message).toBe("Unknown error"); - }); - - it("processes multiple inputs sequentially", async () => { - const inputs = [ - createInput({ collectionId: "doc-1" }), - createInput({ collectionId: "doc-2" }), - createInput({ collectionId: "doc-3" }), - ]; - - await runner.enqueue(inputs); - - expect(mockHandler).toHaveBeenCalledTimes(3); - expect(tasks.get("posts:doc-1:de")?.status).toBe("completed"); - expect(tasks.get("posts:doc-2:de")?.status).toBe("completed"); - expect(tasks.get("posts:doc-3:de")?.status).toBe("completed"); - }); - - it("creates task with unique id", async () => { - const inputs = [ - createInput({ collectionId: "doc-1" }), - createInput({ collectionId: "doc-2" }), - ]; - - await runner.enqueue(inputs); - - const task1 = tasks.get("posts:doc-1:de"); - const task2 = tasks.get("posts:doc-2:de"); - expect(task1?.id).not.toBe(task2?.id); - }); - - it("sets cancelled to false", async () => { - const input = createInput(); - await runner.enqueue([input]); - - const task = tasks.get("posts:doc-123:de"); - expect(task?.cancelled).toBe(false); - }); - - it("overwrites the existing task for the same document AND locale", async () => { - const input = createInput(); - await runner.enqueue([input]); - - const firstTaskId = tasks.get("posts:doc-123:de")?.id; - - await runner.enqueue([input]); - - const secondTaskId = tasks.get("posts:doc-123:de")?.id; - expect(secondTaskId).not.toBe(firstTaskId); - }); - - it("keeps a separate task per target locale for the same document", async () => { - // Translating a second locale of the same document must not evict the first — otherwise - // findByCollection can only ever return one locale's task (the status-panel overwrite bug). - await runner.enqueue([createInput({ targetLng: "de" })]); - await runner.enqueue([createInput({ targetLng: "fr" })]); - - expect(tasks.get("posts:doc-123:de")).toBeDefined(); - expect(tasks.get("posts:doc-123:fr")).toBeDefined(); - - const found = await runner.findByCollection("posts" as CollectionSlug, ["doc-123"]); - expect(found.map((t) => t.input.targetLng).sort()).toEqual(["de", "fr"]); - }); - }); - - describe("cancel", () => { - it("is a no-op (sync tasks cannot be cancelled)", async () => { - const input = createInput(); - await runner.enqueue([input]); - - const taskId = tasks.get("posts:doc-123:de")?.id; - await runner.cancel([taskId!]); - - // Task should still exist and be completed - expect(tasks.get("posts:doc-123:de")?.status).toBe("completed"); - }); - }); - - describe("run", () => { - it("returns not_found error (sync tasks run immediately)", async () => { - const result = await runner.run("some-task-id"); - expect(result).toEqual({ success: false, error: "not_found" }); - }); - }); - - describe("findByCollection", () => { - it("returns tasks for collection", async () => { - await runner.enqueue([ - createInput({ collectionSlug: "posts" as CollectionSlug, collectionId: "doc-1" }), - createInput({ collectionSlug: "posts" as CollectionSlug, collectionId: "doc-2" }), - createInput({ collectionSlug: "pages" as CollectionSlug, collectionId: "doc-3" }), - ]); + await expect(runner.enqueue([createInput()], { transactionID: "tx-1" })).rejects.toBe( + "string error" + ); - const postTasks = await runner.findByCollection("posts" as CollectionSlug); - expect(postTasks).toHaveLength(2); - expect(postTasks.every((t) => t.input.collectionSlug === "posts")).toBe(true); + expect(statesFor("de")).toEqual(["queued", "failed"]); }); - it("filters by documentIds when provided", async () => { + it("processes multiple documents sequentially", async () => { await runner.enqueue([ createInput({ collectionId: "doc-1" }), createInput({ collectionId: "doc-2" }), createInput({ collectionId: "doc-3" }), ]); - const filteredTasks = await runner.findByCollection("posts" as CollectionSlug, [ - "doc-1", - "doc-3", - ]); - expect(filteredTasks).toHaveLength(2); - expect(filteredTasks.map((t) => t.input.collectionId).sort()).toEqual(["doc-1", "doc-3"]); + expect(mockHandler).toHaveBeenCalledTimes(3); }); - it("drops finished tasks when asked to exclude them", async () => { - await runner.enqueue([createInput({ collectionId: "doc-1" })]); + it("passes the caller's scope to the handler and to every report", async () => { + await runner.enqueue([createInput()], { transactionID: "tx-9" }); - const all = await runner.findByCollection("posts" as CollectionSlug); - const unfinished = await runner.findByCollection("posts" as CollectionSlug, { - excludeCompleted: true, + expect(mockHandler).toHaveBeenCalledWith(mockPayload, expect.anything(), { + transactionID: "tx-9", }); - - expect(all.map((t) => t.status)).toEqual(["completed"]); - expect(unfinished).toEqual([]); - }); - - it("returns empty array when no tasks match", async () => { - await runner.enqueue([createInput()]); - - const tasks = await runner.findByCollection("pages" as CollectionSlug); - expect(tasks).toEqual([]); - }); - - it("returns empty array for empty documentIds", async () => { - await runner.enqueue([createInput()]); - - const tasks = await runner.findByCollection("posts" as CollectionSlug, []); - expect(tasks).toEqual([]); }); }); }); diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.transaction.test.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.transaction.test.ts index 2faaa5c6f..60f162b51 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.transaction.test.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/__tests__/SyncTaskRunner.transaction.test.ts @@ -2,8 +2,7 @@ import { describe, it, expect, vi } from "vitest"; import type { Payload, CollectionSlug } from "payload"; import { SyncTaskRunner } from "../SyncTaskRunner.js"; -import type { Task, TaskInput } from "../../types.js"; -import { LazyMap } from "../../../../shared/utils/index.js"; +import type { TaskInput } from "../../types.js"; const task: TaskInput = { collectionSlug: "posts" as CollectionSlug, @@ -15,17 +14,12 @@ const task: TaskInput = { function makeRunner() { const handler = vi.fn().mockResolvedValue(undefined); - const tasks = new LazyMap({ - isRemovable: (t) => t.status === "completed" || t.status === "failed", - getTimestamp: (t) => new Date(t.updatedAt).getTime(), - }); return { handler, - runner: new SyncTaskRunner( - {} as Payload, - { handler, collections: [], report: vi.fn().mockResolvedValue(undefined) }, - tasks - ), + runner: new SyncTaskRunner({} as Payload, { + handler, + report: vi.fn().mockResolvedValue(undefined), + }), }; } diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/types.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/types.ts deleted file mode 100644 index 9ca40011c..000000000 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/sync-runner/types.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Options for SyncRunnerProvider - */ -export type SyncRunnerOptions = { - /** - * Maximum number of task results to keep in memory. - * When exceeded, oldest completed entries are removed. - * @default 100 - */ - maxSize?: number; - /** - * Time-to-live for completed task results in milliseconds. - * Expired entries are removed lazily during enqueue operations. - * @default 3600000 (1 hour) - */ - ttlMs?: number; -}; diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/toTaskFilter.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/toTaskFilter.ts deleted file mode 100644 index e599a29f6..000000000 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/toTaskFilter.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { TaskFilter } from "./TaskRunner.interface.js"; - -/** - * Normalize the two accepted shapes of {@link TaskRunner.findByCollection}'s second argument. - * - * Every implementation of {@link TaskRunner} owes this, so it ships alongside the contract rather - * than being re-derived: the array form is deprecated and will be removed, and a hand-written - * `Array.isArray` branch in someone else's runner would outlive it. - * - * @since 0.11.2 - */ -export function toTaskFilter(filter?: Array | TaskFilter): TaskFilter { - return Array.isArray(filter) ? { documentIds: filter } : (filter ?? {}); -} diff --git a/packages/payload-plugin-translator/src/server/modules/task-runner/types.ts b/packages/payload-plugin-translator/src/server/modules/task-runner/types.ts index 5d4208bd1..534eecdf1 100644 --- a/packages/payload-plugin-translator/src/server/modules/task-runner/types.ts +++ b/packages/payload-plugin-translator/src/server/modules/task-runner/types.ts @@ -92,6 +92,28 @@ export type JobAssignment = { startedBy: { id: string; collection: string } | null; }; +/** + * Which locale of which run a report is about. + * + * @since 1.0.0 + */ +export type RunRef = { + handle: JobHandle; + targetLng: string; +}; + +/** + * What one queued locale was asked to do. + * + * @since 1.0.0 + */ +export type QueuedWork = RunAsk & { + collectionSlug: CollectionSlug; + collectionId: ID; + sourceLng: string; + publishOnTranslation: boolean; +}; + /** * What became of one {@link EnqueueAssignment}. * @@ -103,7 +125,7 @@ export type JobAssignment = { * own — nothing ran for it to throw. */ export type TaskEvent = - | { state: "queued" } + | { state: "queued"; work: QueuedWork } | { state: "delivered" } | { state: "failed"; error: unknown } | { state: "cancelled" }; diff --git a/packages/payload-plugin-translator/src/server/shared/access/__tests__/everyRegisteredRouteIsGuarded.test.ts b/packages/payload-plugin-translator/src/server/shared/access/__tests__/everyRegisteredRouteIsGuarded.test.ts index 6f30b6324..25655b17b 100644 --- a/packages/payload-plugin-translator/src/server/shared/access/__tests__/everyRegisteredRouteIsGuarded.test.ts +++ b/packages/payload-plugin-translator/src/server/shared/access/__tests__/everyRegisteredRouteIsGuarded.test.ts @@ -8,6 +8,7 @@ import type { CollectionConfig, Config, Endpoint, PayloadRequest } from "payload import { translatorPlugin } from "../../../../plugin.js"; import type { TranslatorPluginConfig } from "../../../../plugin.js"; import { createSyncRunner } from "../../../modules/task-runner/sync-runner/index.js"; +import type { TaskRunnerProvider } from "../../../modules/task-runner/index.js"; import type { TranslationProvider } from "../../../../core/domain/translation-providers/index.js"; import type { AccessGuard } from "../types.js"; import { documentTranslation, fieldTranslation } from "../../../../composition/modules/index.js"; @@ -21,11 +22,24 @@ const posts: CollectionConfig = { const refusing = { check: vi.fn().mockReturnValue(false) }; -const registeredRoutes = async (access: AccessGuard = refusing): Promise => { +const canDoEverything: TaskRunnerProvider = { + capabilities: { stop: true, run: true }, + create: () => ({ + enqueue: async () => [], + stop: async (): Promise => undefined, + run: async () => ({ success: false, error: "not_found" }) as const, + }), + configure: () => (config) => config, +}; + +const registeredRoutes = async ( + access: AccessGuard = refusing, + runner: TaskRunnerProvider = canDoEverything +): Promise => { const options: TranslatorPluginConfig = { collections: [posts], translationProvider: provider, - runner: createSyncRunner(), + runner, access, provenance: true, modules: [documentTranslation(), fieldTranslation()], @@ -64,6 +78,29 @@ describe("every route the plugin registers is behind the guard", () => { ]); }); + it("leaves out the routes a runner that only accepts work cannot serve", async () => { + const paths = (await registeredRoutes(refusing, createSyncRunner())).map((e) => e.path).sort(); + + expect( + paths, + "a route with nothing behind it would answer 404 from the control that called it" + ).toEqual([ + "/translate/collection/:collection_slug", + "/translate/document/:collection_slug/:collection_id", + "/translate/enqueue", + "/translate/field", + "/translate/stale/:collection_slug/:collection_id", + "/translate/stale/dismiss", + ]); + }); + + it("keeps the status routes, which rest on the plugin's own record rather than the runner", async () => { + const paths = (await registeredRoutes(refusing, createSyncRunner())).map((e) => e.path); + + expect(paths).toContain("/translate/document/:collection_slug/:collection_id"); + expect(paths).toContain("/translate/collection/:collection_slug"); + }); + it("refuses every one of them when the guard says no", async () => { const routes = await registeredRoutes(); diff --git a/packages/payload-plugin-translator/src/server/shared/access/__tests__/everyRouteNamesItsAction.test.ts b/packages/payload-plugin-translator/src/server/shared/access/__tests__/everyRouteNamesItsAction.test.ts index f82099294..aeedec1e7 100644 --- a/packages/payload-plugin-translator/src/server/shared/access/__tests__/everyRouteNamesItsAction.test.ts +++ b/packages/payload-plugin-translator/src/server/shared/access/__tests__/everyRouteNamesItsAction.test.ts @@ -18,16 +18,21 @@ import type { TranslationProvider } from "../../../../core/domain/translation-pr import type { CollectionSchemaMap } from "../../../../types/CollectionSchemaMap.js"; import type { TranslatorAction } from "../../../../types/TranslatorAction.js"; import type { AccessGuard } from "../types.js"; +import { storeDouble } from "../../../modules/runs/__tests__/fixtures/runRecordStore.js"; const unreachable = (): never => { throw new Error("the guard refuses first, so nothing behind it may run"); }; const availableCollections = new Set(); -const taskRunnerFactory: TaskRunnerFactory = { create: unreachable }; +const taskRunnerFactory: TaskRunnerFactory = { + create: unreachable, + capabilities: { stop: true, run: true }, +}; const provenanceServiceFactory: ProvenanceServiceFactory = unreachable; const translationProvider: TranslationProvider = { translate: unreachable }; const schemaMap: CollectionSchemaMap = new Map(); +const runRecordStoreFactory = () => storeDouble(); type RouteUnderTest = { readonly factory: string; @@ -37,32 +42,43 @@ type RouteUnderTest = { const routeForEveryAction: Record = { enqueue: { factory: "createEnqueueRoute", - build: (access) => createEnqueueRoute(taskRunnerFactory, { availableCollections }, access), + build: (access) => + createEnqueueRoute( + taskRunnerFactory, + runRecordStoreFactory, + { availableCollections }, + access + ), }, run: { factory: "createRunRoute", - build: (access) => createRunRoute(taskRunnerFactory, access), + build: (access) => createRunRoute(taskRunnerFactory, runRecordStoreFactory, access), }, cancel: { factory: "createCancelRoute", - build: (access) => createCancelRoute(taskRunnerFactory, access), + build: (access) => createCancelRoute(taskRunnerFactory, runRecordStoreFactory, access), }, "cancel-by-collection": { factory: "createCancelByCollectionRoute", build: (access) => - createCancelByCollectionRoute({ availableCollections }, taskRunnerFactory, access), + createCancelByCollectionRoute( + { availableCollections }, + taskRunnerFactory, + runRecordStoreFactory, + access + ), }, "document-status": { factory: "createGetDocumentStatusRoute", build: (access) => - createGetDocumentStatusRoute({ availableCollections }, taskRunnerFactory, access), + createGetDocumentStatusRoute({ availableCollections }, runRecordStoreFactory, access), }, "collection-status": { factory: "createGetCollectionStatusRoute", build: (access) => createGetCollectionStatusRoute( { availableCollections, failedWindowMs: 0 }, - taskRunnerFactory, + runRecordStoreFactory, access ), }, diff --git a/packages/payload-plugin-translator/src/server/shared/access/__tests__/theQueueDemandsAnAsk.pins.ts b/packages/payload-plugin-translator/src/server/shared/access/__tests__/theQueueDemandsAnAsk.pins.ts index 9a9d73dff..75c890a19 100644 --- a/packages/payload-plugin-translator/src/server/shared/access/__tests__/theQueueDemandsAnAsk.pins.ts +++ b/packages/payload-plugin-translator/src/server/shared/access/__tests__/theQueueDemandsAnAsk.pins.ts @@ -13,11 +13,11 @@ declare const handle: string; // @ts-expect-error work reaches the queue only once the rules have been asked about its document runner.enqueue(work); -// @ts-expect-error a job is cancelled only once the rules have been asked about its document -runner.cancel([handle]); +// @ts-expect-error work is stopped only once the rules have been asked about its document +runner.stop?.([handle]); // @ts-expect-error a job is run only once the rules have been asked about its document -runner.run(handle); +runner.run?.(handle); declare const unnameable: { kind: "unnameable" }; declare const identity: Identity; @@ -33,19 +33,20 @@ declare const askedHandle: AuthorisedHandle; export const theQueueTakesWhatTheAskProduced = async (): Promise => { await runner.enqueue(asked); - await runner.cancel([askedHandle]); - await runner.run(askedHandle); + await runner.stop?.([askedHandle]); + await runner.run?.(askedHandle); }; /** * A runner written before the brand existed still satisfies the interface: TypeScript compares * method parameters bivariantly, so declaring the wider type is not a break for an implementer. */ -export const aRunnerFromBefore: Pick = { +export const aRunnerFromBefore: Pick = { enqueue: async (tasks: TaskInput[]) => { void tasks; + return []; }, - cancel: async (ids: string[]) => { + stop: async (ids: string[]) => { void ids; }, run: async (id: string) => { diff --git a/packages/payload-plugin-translator/src/server/shared/payload/__tests__/swallowOrThrow.test.ts b/packages/payload-plugin-translator/src/server/shared/payload/__tests__/swallowOrThrow.test.ts index 669ed7306..8ae7a5fa8 100644 --- a/packages/payload-plugin-translator/src/server/shared/payload/__tests__/swallowOrThrow.test.ts +++ b/packages/payload-plugin-translator/src/server/shared/payload/__tests__/swallowOrThrow.test.ts @@ -92,4 +92,26 @@ describe("swallowOrThrow", () => { "read at the catch instead of at entry, the rollback would hide that there was a transaction" ).rejects.toBe(foreign); }); + + it("awaits an onFailure that does work of its own before deciding what to do", async () => { + const order: string[] = []; + // A timer, not a resolved promise: unwinding the awaits on the way out of `swallowOrThrow` + // would outrun a microtask, and the check would pass without the await it exists to guard. + const slowCleanup = async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + order.push("cleanup finished"); + }; + + await swallowOrThrow( + NO_TRANSACTION, + () => Promise.reject(new Error("the work failed")), + slowCleanup + ); + order.push("swallowOrThrow returned"); + + expect(order, "a cleanup that writes must land before the caller carries on").toEqual([ + "cleanup finished", + "swallowOrThrow returned", + ]); + }); }); diff --git a/packages/payload-plugin-translator/src/server/shared/payload/swallowOrThrow.ts b/packages/payload-plugin-translator/src/server/shared/payload/swallowOrThrow.ts index b3e380772..55311481b 100644 --- a/packages/payload-plugin-translator/src/server/shared/payload/swallowOrThrow.ts +++ b/packages/payload-plugin-translator/src/server/shared/payload/swallowOrThrow.ts @@ -8,19 +8,20 @@ import type { RequestScope } from "./RequestScope.shapes.js"; * caller's own transaction with it: both that the caller is inside one, and that the error does not * declare itself harmless. * - * `scope` is read before `work` runs; mutating it during the work has no effect. + * `scope` is read before `work` runs; mutating it during the work has no effect. `onFailure` is + * awaited, so a handler that writes somewhere finishes before the decision below is taken. */ export async function swallowOrThrow( scope: RequestScope, work: () => Promise, - onFailure: (error: unknown) => void + onFailure: (error: unknown) => void | Promise ): Promise { const callerHasWorkToLose = scope.transactionID != null; try { return await work(); } catch (error) { - onFailure(error); + await onFailure(error); if (callerHasWorkToLose && mustPropagate(error)) throw error; return undefined; } diff --git a/packages/payload-plugin-translator/src/server/shared/utils/LazyMap.test.ts b/packages/payload-plugin-translator/src/server/shared/utils/LazyMap.test.ts deleted file mode 100644 index 19b206fab..000000000 --- a/packages/payload-plugin-translator/src/server/shared/utils/LazyMap.test.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { LazyMap } from "./LazyMap.js"; - -type TestEntry = { - id: string; - status: "running" | "completed" | "failed"; - updatedAt: number; -}; - -const createEntry = ( - id: string, - status: TestEntry["status"] = "completed", - updatedAt: number = Date.now() -): TestEntry => ({ - id, - status, - updatedAt, -}); - -const defaultOptions = { - isRemovable: (entry: TestEntry) => entry.status === "completed" || entry.status === "failed", - getTimestamp: (entry: TestEntry) => entry.updatedAt, -}; - -describe("LazyMap", () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - describe("basic Map operations", () => { - it("sets and gets values", () => { - const map = new LazyMap(defaultOptions); - const entry = createEntry("1"); - - map.set("key1", entry); - - expect(map.get("key1")).toBe(entry); - }); - - it("returns undefined for non-existent keys", () => { - const map = new LazyMap(defaultOptions); - - expect(map.get("non-existent")).toBeUndefined(); - }); - - it("deletes values", () => { - const map = new LazyMap(defaultOptions); - map.set("key1", createEntry("1")); - - const result = map.delete("key1"); - - expect(result).toBe(true); - expect(map.get("key1")).toBeUndefined(); - }); - - it("returns false when deleting non-existent key", () => { - const map = new LazyMap(defaultOptions); - - expect(map.delete("non-existent")).toBe(false); - }); - - it("checks if key exists with has()", () => { - const map = new LazyMap(defaultOptions); - map.set("key1", createEntry("1")); - - expect(map.has("key1")).toBe(true); - expect(map.has("key2")).toBe(false); - }); - - it("returns correct size", () => { - const map = new LazyMap(defaultOptions); - - expect(map.size).toBe(0); - - map.set("key1", createEntry("1")); - expect(map.size).toBe(1); - - map.set("key2", createEntry("2")); - expect(map.size).toBe(2); - }); - - it("iterates over values()", () => { - const map = new LazyMap(defaultOptions); - const entry1 = createEntry("1"); - const entry2 = createEntry("2"); - map.set("key1", entry1); - map.set("key2", entry2); - - const values = [...map.values()]; - - expect(values).toEqual([entry1, entry2]); - }); - - it("iterates over entries()", () => { - const map = new LazyMap(defaultOptions); - const entry1 = createEntry("1"); - const entry2 = createEntry("2"); - map.set("key1", entry1); - map.set("key2", entry2); - - const entries = [...map.entries()]; - - expect(entries).toEqual([ - ["key1", entry1], - ["key2", entry2], - ]); - }); - - it("supports for...of iteration", () => { - const map = new LazyMap(defaultOptions); - const entry1 = createEntry("1"); - const entry2 = createEntry("2"); - map.set("key1", entry1); - map.set("key2", entry2); - - const entries: [string, TestEntry][] = []; - for (const entry of map) { - entries.push(entry); - } - - expect(entries).toEqual([ - ["key1", entry1], - ["key2", entry2], - ]); - }); - }); - - describe("TTL cleanup", () => { - it("removes expired removable entries on set()", () => { - const map = new LazyMap({ - ...defaultOptions, - ttlMs: 1000, // 1 second - }); - - const now = Date.now(); - map.set("old", createEntry("old", "completed", now)); - - vi.advanceTimersByTime(1500); // 1.5 seconds later - - map.set("new", createEntry("new", "completed", Date.now())); - - expect(map.has("old")).toBe(false); - expect(map.has("new")).toBe(true); - }); - - it("does not remove non-removable entries even if expired", () => { - const map = new LazyMap({ - ...defaultOptions, - ttlMs: 1000, - }); - - const now = Date.now(); - map.set("running", createEntry("running", "running", now)); - - vi.advanceTimersByTime(1500); - - map.set("new", createEntry("new", "completed", Date.now())); - - expect(map.has("running")).toBe(true); // Still there because status is 'running' - }); - - it("does not remove entries within TTL", () => { - const map = new LazyMap({ - ...defaultOptions, - ttlMs: 1000, - }); - - const now = Date.now(); - map.set("recent", createEntry("recent", "completed", now)); - - vi.advanceTimersByTime(500); // Only 0.5 seconds - - map.set("new", createEntry("new", "completed", Date.now())); - - expect(map.has("recent")).toBe(true); - }); - - it("skips TTL cleanup when ttlMs is Infinity", () => { - const map = new LazyMap({ - ...defaultOptions, - // ttlMs defaults to Infinity - }); - - const oldTime = Date.now(); - map.set("old", createEntry("old", "completed", oldTime)); - - vi.advanceTimersByTime(1000 * 60 * 60 * 24); // 1 day later - - map.set("new", createEntry("new", "completed", Date.now())); - - expect(map.has("old")).toBe(true); // Still there - }); - }); - - describe("maxSize cleanup", () => { - it("removes oldest removable entries when exceeding maxSize", () => { - const map = new LazyMap({ - ...defaultOptions, - maxSize: 2, - }); - - const now = Date.now(); - map.set("entry1", createEntry("1", "completed", now)); - map.set("entry2", createEntry("2", "completed", now + 100)); - map.set("entry3", createEntry("3", "completed", now + 200)); - - expect(map.size).toBe(2); - expect(map.has("entry1")).toBe(false); // Oldest removed - expect(map.has("entry2")).toBe(true); - expect(map.has("entry3")).toBe(true); - }); - - it("removes multiple entries to reach maxSize", () => { - const map = new LazyMap({ - ...defaultOptions, - maxSize: 1, - }); - - const now = Date.now(); - map.set("entry1", createEntry("1", "completed", now)); - map.set("entry2", createEntry("2", "completed", now + 100)); - map.set("entry3", createEntry("3", "completed", now + 200)); - - expect(map.size).toBe(1); - expect(map.has("entry3")).toBe(true); - }); - - it("does not remove non-removable entries", () => { - const map = new LazyMap({ - ...defaultOptions, - maxSize: 2, - }); - - const now = Date.now(); - map.set("running1", createEntry("r1", "running", now)); - map.set("running2", createEntry("r2", "running", now + 100)); - map.set("running3", createEntry("r3", "running", now + 200)); - - // All 3 entries remain because running entries cannot be removed - expect(map.size).toBe(3); - expect(map.has("running1")).toBe(true); - expect(map.has("running2")).toBe(true); - expect(map.has("running3")).toBe(true); - }); - - it("removes removable entries first, keeping non-removable", () => { - const map = new LazyMap({ - ...defaultOptions, - maxSize: 2, - }); - - const now = Date.now(); - map.set("completed1", createEntry("c1", "completed", now)); - map.set("running", createEntry("r1", "running", now + 100)); - map.set("completed2", createEntry("c2", "completed", now + 200)); - - expect(map.size).toBe(2); - expect(map.has("completed1")).toBe(false); // Oldest removable removed - expect(map.has("running")).toBe(true); // Non-removable kept - expect(map.has("completed2")).toBe(true); - }); - }); - - describe("combined TTL and maxSize", () => { - it("applies both TTL and maxSize cleanup", () => { - const map = new LazyMap({ - ...defaultOptions, - maxSize: 3, - ttlMs: 1000, - }); - - const now = Date.now(); - map.set("expired1", createEntry("e1", "completed", now)); - map.set("expired2", createEntry("e2", "completed", now)); - - vi.advanceTimersByTime(1500); - - const laterNow = Date.now(); - map.set("recent1", createEntry("r1", "completed", laterNow)); - map.set("recent2", createEntry("r2", "completed", laterNow + 100)); - map.set("recent3", createEntry("r3", "completed", laterNow + 200)); - - // Expired entries removed by TTL, then maxSize enforced - expect(map.has("expired1")).toBe(false); - expect(map.has("expired2")).toBe(false); - expect(map.size).toBe(3); - }); - }); - - describe("edge cases", () => { - it("handles empty map", () => { - const map = new LazyMap({ - ...defaultOptions, - maxSize: 5, - ttlMs: 1000, - }); - - expect(map.size).toBe(0); - expect([...map.values()]).toEqual([]); - }); - - it("handles maxSize of 0", () => { - const map = new LazyMap({ - ...defaultOptions, - maxSize: 0, - }); - - map.set("key1", createEntry("1", "completed", Date.now())); - - // Entry is removed immediately because maxSize is 0 - expect(map.size).toBe(0); - }); - - it("overwrites existing key without affecting count", () => { - const map = new LazyMap({ - ...defaultOptions, - maxSize: 2, - }); - - const now = Date.now(); - map.set("key1", createEntry("1", "completed", now)); - map.set("key2", createEntry("2", "completed", now + 100)); - - // Overwrite key1 - map.set("key1", createEntry("1-updated", "completed", now + 200)); - - expect(map.size).toBe(2); - expect(map.get("key1")?.id).toBe("1-updated"); - }); - - it("handles failed status as removable", () => { - const map = new LazyMap({ - ...defaultOptions, - maxSize: 1, - }); - - const now = Date.now(); - map.set("failed", createEntry("f1", "failed", now)); - map.set("new", createEntry("n1", "completed", now + 100)); - - expect(map.size).toBe(1); - expect(map.has("failed")).toBe(false); - expect(map.has("new")).toBe(true); - }); - }); -}); diff --git a/packages/payload-plugin-translator/src/server/shared/utils/LazyMap.ts b/packages/payload-plugin-translator/src/server/shared/utils/LazyMap.ts deleted file mode 100644 index 7c37b4826..000000000 --- a/packages/payload-plugin-translator/src/server/shared/utils/LazyMap.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Options for LazyMap configuration. - */ -export type LazyMapOptions = { - /** - * Maximum number of entries to keep in the map. - * When exceeded, oldest removable entries are deleted. - * @default Infinity - */ - maxSize?: number; - /** - * Time-to-live for removable entries in milliseconds. - * Expired entries are removed lazily during set operations. - * @default Infinity - */ - ttlMs?: number; - /** - * Predicate to determine which entries can be removed during cleanup. - * Returns true if the entry is eligible for removal. - */ - isRemovable: (value: V) => boolean; - /** - * Function to extract timestamp from a value for age calculation. - * Used for TTL expiration and oldest-first removal. - */ - getTimestamp: (value: V) => number; -}; - -/** - * A Map wrapper with lazy cleanup capabilities. - * - * Automatically removes expired and excess entries during set operations. - * Uses lazy cleanup pattern (no timers) - cleanup runs only when needed. - * - * @example - * ```typescript - * const tasks = new LazyMap({ - * maxSize: 100, - * ttlMs: 60 * 60 * 1000, // 1 hour - * isRemovable: (task) => task.status === 'completed' || task.status === 'failed', - * getTimestamp: (task) => new Date(task.updatedAt).getTime(), - * }) - * - * tasks.set('task-1', task) // Cleanup runs automatically - * ``` - */ -export class LazyMap { - private readonly map = new Map(); - private readonly maxSize: number; - private readonly ttlMs: number; - private readonly isRemovable: (value: V) => boolean; - private readonly getTimestamp: (value: V) => number; - - constructor(options: LazyMapOptions) { - this.maxSize = options.maxSize ?? Infinity; - this.ttlMs = options.ttlMs ?? Infinity; - this.isRemovable = options.isRemovable; - this.getTimestamp = options.getTimestamp; - } - - /** - * Sets a key-value pair, triggering cleanup after. - */ - set(key: K, value: V): void { - this.map.set(key, value); - this.cleanup(); - } - - get(key: K): V | undefined { - return this.map.get(key); - } - - delete(key: K): boolean { - return this.map.delete(key); - } - - has(key: K): boolean { - return this.map.has(key); - } - - values(): IterableIterator { - return this.map.values(); - } - - entries(): IterableIterator<[K, V]> { - return this.map.entries(); - } - - [Symbol.iterator](): IterableIterator<[K, V]> { - return this.map[Symbol.iterator](); - } - - get size(): number { - return this.map.size; - } - - /** - * Remove expired entries and enforce maxSize limit. - * Called automatically during set() operations. - */ - private cleanup(): void { - const now = Date.now(); - - // 1. Remove expired entries - if (this.ttlMs !== Infinity) { - for (const [key, value] of this.map) { - if (!this.isRemovable(value)) continue; - if (now - this.getTimestamp(value) > this.ttlMs) { - this.map.delete(key); - } - } - } - - // 2. Enforce maxSize by removing oldest removable entries - while (this.map.size > this.maxSize) { - const oldestKey = this.findOldestRemovableKey(); - if (!oldestKey) break; // Only non-removable entries left - this.map.delete(oldestKey); - } - } - - private findOldestRemovableKey(): K | undefined { - let oldestKey: K | undefined; - let oldestTime = Infinity; - - for (const [key, value] of this.map) { - if (!this.isRemovable(value)) continue; - const timestamp = this.getTimestamp(value); - if (timestamp < oldestTime) { - oldestTime = timestamp; - oldestKey = key; - } - } - - return oldestKey; - } -} diff --git a/packages/payload-plugin-translator/src/server/shared/utils/index.ts b/packages/payload-plugin-translator/src/server/shared/utils/index.ts index 8b29d7062..19c9a05eb 100644 --- a/packages/payload-plugin-translator/src/server/shared/utils/index.ts +++ b/packages/payload-plugin-translator/src/server/shared/utils/index.ts @@ -5,5 +5,3 @@ export { getByPath } from "./getByPath.js"; export { setByPath } from "./setByPath.js"; export { pipe } from "./pipe.js"; export { filterLocalizedFields } from "./filterLocalizedFields.js"; -export { LazyMap } from "./LazyMap.js"; -export type { LazyMapOptions } from "./LazyMap.js"; diff --git a/packages/payload-plugin-translator/src/testing/TaskRunner.invariants.ts b/packages/payload-plugin-translator/src/testing/TaskRunner.invariants.ts index 3aeb1148b..3dc39c082 100644 --- a/packages/payload-plugin-translator/src/testing/TaskRunner.invariants.ts +++ b/packages/payload-plugin-translator/src/testing/TaskRunner.invariants.ts @@ -2,10 +2,12 @@ import { beforeEach, describe, expect, it } from "vitest"; import type { TaskRunner } from "../server/modules/task-runner/TaskRunner.interface.js"; import type { TaskRunnerContext } from "../server/modules/task-runner/TaskRunnerProvider.interface.js"; +import type { RunnerCapabilities } from "../server/modules/task-runner/RunnerCapabilities.js"; import type { EnqueueAssignment, TaskEvent, TaskInput, + RunRef, } from "../server/modules/task-runner/types.js"; import type { AuthorisedHandle, AuthorisedWork } from "../server/shared/access/authorised.js"; @@ -28,8 +30,12 @@ type Addressed = { collectionSlug: string; collectionId: string; targetLng: stri const addressOf = (target: Addressed): string => `${target.collectionSlug}/${target.collectionId}/${target.targetLng}`; -const assignmentKey = (assignment: EnqueueAssignment): string => - `${assignment.handle}@${addressOf(assignment)}`; +const runKey = (run: RunRef): string => `${run.handle}@${run.targetLng}`; + +const refOf = (assignment: EnqueueAssignment): RunRef => ({ + handle: assignment.handle, + targetLng: assignment.targetLng, +}); type ReportState = TaskEvent["state"]; @@ -47,7 +53,7 @@ const settle = async (promise: Promise): Promise<"resolved" | "rejected } }; -type Reported = { assignment: EnqueueAssignment; event: TaskEvent }; +type Reported = { run: RunRef; event: TaskEvent }; const handlesOf = (answer: EnqueueAssignment[]): AuthorisedHandle[] => [...new Set(answer.map((assignment) => assignment.handle))].map(asHandle); @@ -69,11 +75,18 @@ export type MakeTaskRunner = ( * Importing this module loads `vitest`, so it belongs in a test file; a non-test module that * imports it fails at load, not at the call. * + * The checks for `stop` and `run` are registered only when the provider declares those + * capabilities, and the suite first asserts that what it declares, the runner implements. + * * @param name - how the implementation under test is reported * @param make - called fresh for every check * @since 1.0.0 */ -export function assertTaskRunnerContract(name: string, make: MakeTaskRunner): void { +export function assertTaskRunnerContract( + name: string, + make: MakeTaskRunner, + capabilities: RunnerCapabilities +): void { describe(name, () => { let runner: TaskRunner; let reported: Reported[] = []; @@ -81,8 +94,8 @@ export function assertTaskRunnerContract(name: string, make: MakeTaskRunner): vo let checkNumber = 0; const documentForThisCheck = (n: number): string => `doc-${checkNumber}-${n}`; - const record: TaskRunnerContext["report"] = (_payload, assignment, event) => { - reported.push({ assignment, event }); + const record: TaskRunnerContext["report"] = (_payload, run, event) => { + reported.push({ run, event }); return Promise.resolve(); }; @@ -108,31 +121,48 @@ export function assertTaskRunnerContract(name: string, make: MakeTaskRunner): vo input(documentForThisCheck(2), "de"), ]; - const addressesReported = (state: ReportState): string[] => - reported - .filter((entry) => entry.event.state === state) - .map((entry) => addressOf(entry.assignment)); - - const timesQueued = (address: string): number => - addressesReported("queued").filter((queued) => queued === address).length; + const timesReported = (run: RunRef, state: ReportState): number => + reported.filter((entry) => entry.event.state === state && runKey(entry.run) === runKey(run)) + .length; const wasReported = (assignment: EnqueueAssignment, state: ReportState): boolean => - reported.some( - (entry) => - entry.event.state === state && - assignmentKey(entry.assignment) === assignmentKey(assignment) - ); + timesReported(refOf(assignment), state) > 0; const terminalsPerAssignment = (): Array<[string, ReportState[]]> => { const collected = new Map(); - for (const { assignment, event } of reported) { + for (const { run, event } of reported) { if (!isTerminal(event)) continue; - const key = assignmentKey(assignment); + const key = runKey(run); collected.set(key, [...(collected.get(key) ?? []), event.state]); } return [...collected]; }; + describe("a runner does what its provider says it does", () => { + it("implements every capability the provider declares, and nothing it denies", () => { + expect( + { stop: typeof runner.stop === "function", run: typeof runner.run === "function" }, + "a declared capability decides whether a route exists, so it cannot be a wish" + ).toEqual({ stop: capabilities.stop, run: capabilities.run }); + }); + }); + + describe("the plugin's record of the run is load-bearing", () => { + it("lets a failing queued report out of enqueue", async () => { + // Only `queued` refuses: a runner whose terminal reports also failed would reject for that + // reason instead, and the check would pass over a runner that swallows this one. + const strict = await make((_payload, _assignment, event) => + event.state === "queued" + ? Promise.reject(new Error("the run was not recorded")) + : Promise.resolve() + ); + + const outcome = await settle(Promise.resolve(strict.enqueue(twoLocales()))); + + expect(outcome, "a run nothing recorded must not answer as queued").toBe("rejected"); + }); + }); + describe("an empty request is answered, not refused", () => { it("answers an empty enqueue", async () => { const answer: unknown = await runner.enqueue([]); @@ -140,23 +170,11 @@ export function assertTaskRunnerContract(name: string, make: MakeTaskRunner): vo expect(answer ?? [], "enqueue([]) answers [] or nothing").toEqual([]); }); - it("answers an empty cancel", async () => { - const answer: unknown = await runner.cancel([]); - - expect(answer ?? [], "cancel([]) answers [] or undefined").toEqual([]); - }); - it("reports nothing about an empty enqueue", async () => { await runner.enqueue([]); expect(reported, "an empty request accepts no assignment to report").toEqual([]); }); - - it("reports nothing about an empty cancel", async () => { - await runner.cancel([]); - - expect(reported, "no handle names work to stop, so nothing settled").toEqual([]); - }); }); describe("a handle is a non-empty string", () => { @@ -171,19 +189,6 @@ export function assertTaskRunnerContract(name: string, make: MakeTaskRunner): vo expect(unusable, "a handle callers can store and hand back").toEqual([]); }); - - it("takes its own handles back", async (ctx) => { - const answer = await enqueued([input(documentForThisCheck(1), "de")]); - if (!answer) { - ctx.skip(); - return; - } - - expect( - await settle(runner.cancel(handlesOf(answer))), - "cancel takes the runner's own handle back unchanged" - ).toBe("resolved"); - }); }); describe("only what was asked for is answered", () => { @@ -261,48 +266,54 @@ export function assertTaskRunnerContract(name: string, make: MakeTaskRunner): vo }); describe("what became of every accepted assignment reaches the plugin", () => { - it("reports queued for every requested locale", async () => { - const request = twoLocales(); - - await runner.enqueue(request); + it("reports queued for every locale it accepted", async (ctx) => { + const answer = await enqueued(twoLocales()); + if (!answer) { + ctx.skip(); + return; + } - const silent = [...new Set(request.map(addressOf))].filter( - (address) => timesQueued(address) === 0 - ); - expect(silent, "queued is sent for every locale the runner accepted").toEqual([]); + const silent = answer.filter((entry) => timesReported(refOf(entry), "queued") === 0); + expect( + silent.map(addressOf), + "queued is sent for every locale the runner accepted" + ).toEqual([]); }); - it("reports queued at most once per requested locale", async () => { - const request = twoLocales(); - - await runner.enqueue(request); + it("reports queued at most once per accepted locale", async (ctx) => { + const answer = await enqueued(twoLocales()); + if (!answer) { + ctx.skip(); + return; + } - const repeated = [...new Set(request.map(addressOf))].filter( - (address) => timesQueued(address) > 1 - ); - expect(repeated, "one queued per requested locale, not several").toEqual([]); + const repeated = answer.filter((entry) => timesReported(refOf(entry), "queued") > 1); + expect(repeated.map(addressOf), "one queued per locale, not several").toEqual([]); }); - it("reports queued once although the request listed a locale twice", async () => { + it("reports queued once although the request listed a locale twice", async (ctx) => { const twice = [input(documentForThisCheck(1), "de"), input(documentForThisCheck(1), "de")]; - - await runner.enqueue(twice); + const answer = await enqueued(twice); + if (!answer || answer.length === 0) { + ctx.skip(); + return; + } expect( - timesQueued(addressOf(twice[0])), + timesReported(refOf(answer[0]), "queued"), "a locale listed twice is one piece of accepted work" ).toBeLessThanOrEqual(1); }); it("reports only about locales the request listed", async () => { const request = twoLocales(); - const asked = new Set(request.map(addressOf)); + const asked = new Set(request.map((task) => task.targetLng)); await runner.enqueue(request); const unasked = reported - .map((entry) => addressOf(entry.assignment)) - .filter((address) => !asked.has(address)); + .map((entry) => entry.run.targetLng) + .filter((targetLng) => !asked.has(targetLng)); expect(unasked, "every report names a locale the request listed").toEqual([]); }); @@ -317,11 +328,10 @@ export function assertTaskRunnerContract(name: string, make: MakeTaskRunner): vo .slice(0, index) .some( (earlier) => - earlier.event.state === "queued" && - addressOf(earlier.assignment) === addressOf(entry.assignment) + earlier.event.state === "queued" && runKey(earlier.run) === runKey(entry.run) ) ) - .map((entry) => `${addressOf(entry.assignment)}:${entry.event.state}`); + .map((entry) => `${runKey(entry.run)}:${entry.event.state}`); expect(premature, "queued comes before any terminal event for that locale").toEqual([]); }); @@ -335,7 +345,38 @@ export function assertTaskRunnerContract(name: string, make: MakeTaskRunner): vo }); }); - describe("cancelling reports what the run still owed", () => { + const stopping = capabilities.stop + ? (handles: AuthorisedHandle[]) => (runner.stop as NonNullable)(handles) + : undefined; + + describe.runIf(capabilities.stop)("stopping is answered, not refused", () => { + it("answers an empty stop", async () => { + const answer: unknown = await stopping?.([]); + + expect(answer ?? [], "stop([]) answers [] or undefined").toEqual([]); + }); + + it("reports nothing about an empty stop", async () => { + await stopping?.([]); + + expect(reported, "no handle names work to stop, so nothing settled").toEqual([]); + }); + + it("takes its own handles back", async (ctx) => { + const answer = await enqueued([input(documentForThisCheck(1), "de")]); + if (!answer) { + ctx.skip(); + return; + } + + expect( + await settle(Promise.resolve(stopping?.(handlesOf(answer)))), + "stop takes the runner's own handle back unchanged" + ).toBe("resolved"); + }); + }); + + describe.runIf(capabilities.stop)("stopping reports what the run still owed", () => { it("reports cancelled for every locale the run still owed", async (ctx) => { const answer = await enqueued(twoLocales()); if (!answer) { @@ -345,13 +386,11 @@ export function assertTaskRunnerContract(name: string, make: MakeTaskRunner): vo const owed = answer.filter( (assignment) => !reported.some( - (entry) => - isTerminal(entry.event) && - assignmentKey(entry.assignment) === assignmentKey(assignment) + (entry) => isTerminal(entry.event) && runKey(entry.run) === runKey(refOf(assignment)) ) ); - await runner.cancel(handlesOf(answer)); + await stopping?.(handlesOf(answer)); const unheard = owed.filter((assignment) => !wasReported(assignment, "cancelled")); expect(unheard.map(addressOf), "cancelled for each locale the run still owed").toEqual([]); @@ -365,7 +404,7 @@ export function assertTaskRunnerContract(name: string, make: MakeTaskRunner): vo } const delivered = answer.filter((assignment) => wasReported(assignment, "delivered")); - await runner.cancel(handlesOf(answer)); + await stopping?.(handlesOf(answer)); const revoked = delivered.filter((assignment) => wasReported(assignment, "cancelled")); expect(revoked.map(addressOf), "a delivered locale is not reported cancelled").toEqual([]); @@ -378,7 +417,7 @@ export function assertTaskRunnerContract(name: string, make: MakeTaskRunner): vo return; } - await runner.cancel(handlesOf(answer)); + await stopping?.(handlesOf(answer)); const several = terminalsPerAssignment().filter( ([, states]) => states.length > 1 && !cancelledAndStillDelivered(states) @@ -398,126 +437,61 @@ export function assertTaskRunnerContract(name: string, make: MakeTaskRunner): vo } const given = new Set(handlesOf(cancelled)); const untouched = new Set( - spared.filter((a) => !given.has(asHandle(a.handle))).map(assignmentKey) + spared.filter((a) => !given.has(asHandle(a.handle))).map((a) => runKey(refOf(a))) ); - await runner.cancel([...given]); + await stopping?.([...given]); const strays = reported.filter( - (entry) => - entry.event.state === "cancelled" && untouched.has(assignmentKey(entry.assignment)) + (entry) => entry.event.state === "cancelled" && untouched.has(runKey(entry.run)) ); expect( - strays.map((entry) => addressOf(entry.assignment)), + strays.map((entry) => runKey(entry.run)), "cancel stops the work these handles stand for, not another run's" ).toEqual([]); }); }); - describe("a handle that no longer names anything is an answer, not a failure", () => { - /** - * A handle this runner issued and then had cancelled. Unknown now, and shaped the way this - * runner shapes handles — which is as far as the obligations reach: a store may reject a - * value it could never have produced, and on SQL a non-numeric handle does exactly that. - */ - const spent = async (): Promise => { - const answer = await enqueued([input(documentForThisCheck(9), "de")]); - const handle = answer?.[0]?.handle; - if (handle) await runner.cancel([asHandle(handle)]); - return handle; - }; - - it("answers run on a spent handle with not_found", async (ctx) => { - const handle = await spent(); - if (!handle) { - ctx.skip(); - return; - } - - expect(await runner.run(asHandle(handle))).toEqual({ success: false, error: "not_found" }); - }); - - it("resolves cancel on a spent handle", async (ctx) => { - const handle = await spent(); - if (!handle) { - ctx.skip(); - return; - } - - expect( - await settle(runner.cancel([asHandle(handle)])), - "cancel on a spent handle resolves rather than rejecting" - ).toBe("resolved"); - }); - }); - - describe("findByCollection finds tasks for a collection", () => { - it("answers only tasks of the collection asked about", async () => { - await runner.enqueue([input(documentForThisCheck(1), "de")]); - - const tasks = await runner.findByCollection(SLUG); - - expect( - tasks.filter((task) => task.input.collectionSlug !== SLUG), - "every task belongs to the collection asked about" - ).toEqual([]); - }); - - it("keeps only the documents the filter names", async () => { - const kept = documentForThisCheck(1); - await runner.enqueue([input(kept, "de"), input(documentForThisCheck(2), "de")]); - - const tasks = await runner.findByCollection(SLUG, { documentIds: [kept] }); - - expect( - tasks.filter((task) => task.input.collectionId !== kept), - "documentIds keeps only tasks for these documents" - ).toEqual([]); - }); - - it("keeps only the documents the deprecated array names", async () => { - const kept = documentForThisCheck(1); - await runner.enqueue([input(kept, "de"), input(documentForThisCheck(2), "de")]); - - const tasks = await runner.findByCollection(SLUG, [kept]); - - expect( - tasks.filter((task) => task.input.collectionId !== kept), - "the array means what { documentIds } means" - ).toEqual([]); - }); - - it("answers about no documents, not about all of them", async () => { - await runner.enqueue([input(documentForThisCheck(1), "de")]); - - expect( - await runner.findByCollection(SLUG, []), - "an empty list names no document, so it cannot mean every document" - ).toEqual([]); - }); - - it("rejects nothing but a run that gave up, whatever the cutoff", async () => { - const live = documentForThisCheck(1); - await runner.enqueue([input(live, "de")]); - - const tasks = await runner.findByCollection(SLUG, { excludeGivenUpBefore: new Date() }); - - expect( - tasks.map((task) => task.input.collectionId), - "a cutoff on give-up is not a cutoff on age" - ).toContain(live); - }); - - it("drops tasks that have finished", async () => { - await runner.enqueue([input(documentForThisCheck(1), "de")]); - - const tasks = await runner.findByCollection(SLUG, { excludeCompleted: true }); - - expect( - tasks.filter((task) => task.status === "completed"), - "excludeCompleted drops tasks that have finished" - ).toEqual([]); - }); - }); + describe.runIf(capabilities.stop && capabilities.run)( + "a handle that no longer names anything is an answer, not a failure", + () => { + /** + * A handle this runner issued and then had cancelled — a made-up one would be rejected by + * the store rather than answered `not_found` (see `TaskRunner.interface.ts`). + */ + const spent = async (): Promise => { + const answer = await enqueued([input(documentForThisCheck(1), "de")]); + const handle = answer?.[0]?.handle; + if (handle) await stopping?.([asHandle(handle)]); + return handle; + }; + + it("answers run on a spent handle with not_found", async (ctx) => { + const handle = await spent(); + if (!handle) { + ctx.skip(); + return; + } + + expect(await runner.run?.(asHandle(handle))).toEqual({ + success: false, + error: "not_found", + }); + }); + + it("resolves stop on a spent handle", async (ctx) => { + const handle = await spent(); + if (!handle) { + ctx.skip(); + return; + } + + expect( + await settle(Promise.resolve(stopping?.([asHandle(handle)]))), + "stop on a spent handle resolves rather than rejecting" + ).toBe("resolved"); + }); + } + ); }); }