[web] admit and report unpublish jobs #170

Merged
eleith merged 1 commits from feat/unpublish-api into main 2026-10-30 01:06:05 +00:00
10 changed files with 275 additions and 15 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
"@luzzle/web": patch
---
Add the authenticated Unpublish API and include it in publication admission and
job status streaming. Require a fresh check after an Unpublish request.
@@ -62,7 +62,9 @@ describe('findInFlightPublishRun', () => {
{ name: 'Publish', status: 'pending' },
{ name: 'Publish', status: 'running' },
{ name: 'PublishAudit', status: 'pending' },
{ name: 'PublishAudit', status: 'running' }
{ name: 'PublishAudit', status: 'running' },
{ name: 'Unpublish', status: 'pending' },
{ name: 'Unpublish', status: 'running' }
])('finds an older $status $name behind a newer completed run', async ({ name, status }) => {
const old = await createPendingRun(queue.backend, name)
await claimNextRun(queue.backend)
@@ -141,7 +143,8 @@ describe('findInFlightPublishRun', () => {
expect(await findInFlightPublishRun(queue.backend)).toBeNull()
expect(list.mock.calls.map(([params]) => params)).toEqual([
{ workflowName: 'Publish', limit: 5 },
{ workflowName: 'PublishAudit', limit: 5 }
{ workflowName: 'PublishAudit', limit: 5 },
{ workflowName: 'Unpublish', limit: 5 }
])
expect(count).not.toHaveBeenCalled()
})
@@ -224,6 +227,54 @@ describe('validateAuditForPublish', () => {
})
})
test.each(['pending', 'completed', 'failed'])(
'rejects an audit after a later %s Unpublish request',
async (status) => {
const audit = makeRun()
mocks.getWorkflowRun.mockResolvedValue(audit)
mocks.getLatestWorkflowRun.mockImplementation(async (_backend, name) => {
if (name === 'PublishAudit') return audit
if (name === 'Unpublish') {
return makeRun({
id: 'unpublish-1',
workflow_name: 'Unpublish',
status,
created_at: '2026-06-20T01:00:00Z'
})
}
return null
})
expect(await validateAuditForPublish(backend, audit.id)).toEqual({
ok: false,
reason: 'an unpublish was requested after this check; re-check before publishing'
})
}
)
test('treats equal creation timestamps as stale rather than guessing request order', async () => {
const audit = makeRun()
mocks.getWorkflowRun.mockResolvedValue(audit)
mocks.getLatestWorkflowRun.mockImplementation(async (_backend, name) => {
if (name === 'PublishAudit') return audit
if (name === 'Unpublish') return makeRun({ workflow_name: 'Unpublish' })
return null
})
expect((await validateAuditForPublish(backend, audit.id)).ok).toBe(false)
})
test('accepts a fresh audit made after Unpublish', async () => {
const audit = makeRun({ created_at: '2026-06-20T02:00:00Z' })
mocks.getWorkflowRun.mockResolvedValue(audit)
mocks.getLatestWorkflowRun.mockImplementation(async (_backend, name) => {
if (name === 'PublishAudit') return audit
if (name === 'Unpublish') {
return makeRun({ workflow_name: 'Unpublish', created_at: '2026-06-20T01:00:00Z' })
}
return null
})
expect(await validateAuditForPublish(backend, audit.id)).toEqual({ ok: true })
})
test('accepts a completed audit that is the latest with no later publish', async () => {
mocks.getWorkflowRun.mockResolvedValue(makeRun({ id: 'audit-1' }))
mocks.getLatestWorkflowRun.mockImplementation(async (_backend, name) =>
+11 -1
View File
@@ -2,6 +2,8 @@ import type { BackendSqlite } from 'openworkflow/sqlite'
import { getLatestWorkflowRun, getWorkflowRun, type PublishPieceFailure } from '@luzzle/web.jobs'
import type { PiecesDiff } from '@luzzle/core'
export const publicationWorkflowNames = ['Publish', 'PublishAudit', 'Unpublish']
const COMPLETED_STATES = new Set(['completed', 'succeeded'])
let publishAdmission: Promise<void> = Promise.resolve()
@@ -22,7 +24,7 @@ export async function withPublishAdmission<T>(operation: () => Promise<T>): Prom
export async function findInFlightPublishRun(
backend: Pick<BackendSqlite, 'listWorkflowRuns'>
): Promise<{ id: string } | null> {
for (const workflowName of ['Publish', 'PublishAudit']) {
for (const workflowName of publicationWorkflowNames) {
const { data } = await backend.listWorkflowRuns({ workflowName, limit: 5 })
const active = data.find((run) => run.status === 'pending' || run.status === 'running')
if (active) return { id: active.id }
@@ -65,6 +67,14 @@ export async function validateAuditForPublish(
}
}
const lastUnpublish = await getLatestWorkflowRun(backend, 'Unpublish')
if (lastUnpublish && lastUnpublish.created_at >= run.created_at) {
return {
ok: false,
reason: 'an unpublish was requested after this check; re-check before publishing'
}
}
if (filePaths !== undefined) {
const diff = parsePiecesDiff(run.output)
if (!diff) {
@@ -407,6 +407,53 @@ describe('streamJobProgress', () => {
])
})
const unpublishError = JSON.stringify({
message: 'Unpublish cleanup incomplete: remote file.png'
})
test.each([
{ status: 'completed', runError: null, expectedResult: 'ok', expectedErrors: null },
{
status: 'failed',
runError: unpublishError,
expectedResult: null,
expectedErrors: [unpublishError]
}
])(
'reports a $status Unpublish outcome and preserves diagnostic details',
async ({ status, runError, expectedResult, expectedErrors }) => {
mocks.getWorkflowRun.mockResolvedValue({
...run,
workflow_name: 'Unpublish',
status,
error: runError
})
mocks.getStepAttempts.mockResolvedValue([])
const chunks = await readChunks(
openStream({ jobClass: ['Publish', 'PublishAudit', 'Unpublish'] })
)
const outcome = JSON.stringify({
state: status,
result: expectedResult,
errors: expectedErrors,
workflowName: 'Unpublish'
})
expect(chunks).toEqual([
`event: state\ndata: ${outcome}\n\n`,
'event: phase\ndata: []\n\n',
`event: done\ndata: ${outcome}\n\n`
])
expect(mocks.query.execute).not.toHaveBeenCalled()
}
)
test('does not present an Unpublish run as a Publish-only stream', async () => {
mocks.getWorkflowRun.mockResolvedValue({ ...run, workflow_name: 'Unpublish' })
expect(await readChunks(openStream())).toEqual([
'event: error\ndata: {"message":"Job is not a Publish job"}\n\n'
])
expect(mocks.getStepAttempts).not.toHaveBeenCalled()
})
test('reports a completed publish with failed pieces as partial, never as ok', async () => {
const failedPieces = [{ filePath: 'failed.book.md', message: 'missing attachment' }]
mocks.getWorkflowRun.mockResolvedValue({ ...run, output: JSON.stringify({ failedPieces }) })
@@ -426,7 +473,7 @@ describe('streamJobProgress', () => {
expect(chunks.join('')).not.toContain('"result":"ok"')
})
test.each(['Preview', 'PublishAudit'])(
test.each(['Preview', 'PublishAudit', 'Unpublish'])(
'keeps non-publish completion semantics unchanged for %s',
async (workflow_name) => {
mocks.getWorkflowRun.mockResolvedValue({
@@ -441,7 +488,7 @@ describe('streamJobProgress', () => {
state: 'completed',
result: 'ok',
errors: null,
...(workflow_name === 'PublishAudit' ? { workflowName: workflow_name } : {})
...(workflow_name !== 'Preview' ? { workflowName: workflow_name } : {})
}
expect(chunks[0]).toBe(`event: state\ndata: ${JSON.stringify(outcome)}\n\n`)
expect(chunks.join('')).not.toContain('failedPieces')
+2 -2
View File
@@ -3,7 +3,7 @@ import { createEventStream } from '../sse.js'
import { WORKFLOW_POLL_INTERVAL_MS } from '../constants.js'
import { getOpenWorkflowBackend } from './index.js'
import { getWorkflowRun, getStepAttempts, type PublishPieceFailure } from '@luzzle/web.jobs'
import { parsePublishFailures } from './publish.js'
import { parsePublishFailures, publicationWorkflowNames } from './publish.js'
const TERMINAL_STATES = new Set(['completed', 'failed', 'canceled'])
@@ -124,7 +124,7 @@ async function pollOnce(
result: job.result,
errors: job.errors
}
if (job.class === 'Publish' || job.class === 'PublishAudit') {
if (publicationWorkflowNames.includes(job.class)) {
outcome.workflowName = job.class
}
if (failedPieces.length > 0) {
@@ -1,10 +1,11 @@
import type { RequestHandler } from './$types'
import { streamJobProgress } from '$lib/server/workflow/stream.js'
import { publicationWorkflowNames } from '$lib/server/workflow/publish.js'
export const GET: RequestHandler = ({ params, request, url }) => {
const jobId = params.id
if (!jobId) {
return new Response('Invalid job ID', { status: 400 })
}
return streamJobProgress({ jobId, jobClass: ['Publish', 'PublishAudit'], request, url })
return streamJobProgress({ jobId, jobClass: publicationWorkflowNames, request, url })
}
@@ -11,6 +11,7 @@ import {
} from '$lib/server/workflow/publish.fixture.js'
import { POST as publish } from './+server.js'
import { POST as audit } from './audit/+server.js'
import { POST as unpublish } from '../unpublish/+server.js'
const { runWorkflow } = vi.hoisted(() => ({ runWorkflow: vi.fn() }))
vi.mock('$lib/server/workflow/index.js', () => ({
@@ -27,20 +28,23 @@ const auditDiff = {
}
}
function event(body: unknown = {}) {
function event(body: unknown = { filePath: 'books/one.books.md' }) {
return {
request: new Request('http://localhost/api/admin/publish', {
method: 'POST',
body: JSON.stringify(body)
})
} as Parameters<typeof publish>[0] & Parameters<typeof audit>[0]
} as Parameters<typeof publish>[0] & Parameters<typeof audit>[0] & Parameters<typeof unpublish>[0]
}
describe('publish/audit admission', () => {
describe('publication admission', () => {
let queue: WorkflowQueue
async function enqueue(spec: { name: string }, input: { bisync: boolean; filePaths?: string[] }) {
vi.setSystemTime(new Date('2026-06-21T00:00:00Z'))
async function enqueue(
spec: { name: string },
input: { bisync?: boolean; filePaths?: string[]; filePath?: string }
) {
vi.setSystemTime(new Date(Date.now() + 1000))
const workflowRun = await createPendingRun(queue.backend, spec.name, input)
return { workflowRun }
}
@@ -62,7 +66,12 @@ describe('publish/audit admission', () => {
{ firstName: 'Publish', firstRoute: publish, otherName: 'Publish', otherRoute: publish },
{ firstName: 'Publish', firstRoute: publish, otherName: 'PublishAudit', otherRoute: audit },
{ firstName: 'PublishAudit', firstRoute: audit, otherName: 'Publish', otherRoute: publish },
{ firstName: 'PublishAudit', firstRoute: audit, otherName: 'PublishAudit', otherRoute: audit }
{ firstName: 'PublishAudit', firstRoute: audit, otherName: 'PublishAudit', otherRoute: audit },
{ firstName: 'Publish', firstRoute: publish, otherName: 'Unpublish', otherRoute: unpublish },
{ firstName: 'PublishAudit', firstRoute: audit, otherName: 'Unpublish', otherRoute: unpublish },
{ firstName: 'Unpublish', firstRoute: unpublish, otherName: 'Publish', otherRoute: publish },
{ firstName: 'Unpublish', firstRoute: unpublish, otherName: 'PublishAudit', otherRoute: audit },
{ firstName: 'Unpublish', firstRoute: unpublish, otherName: 'Unpublish', otherRoute: unpublish }
])(
'admits only one overlapping $firstName/$otherName request',
async ({ firstRoute, otherRoute }) => {
@@ -182,7 +191,7 @@ describe('publish/audit admission', () => {
expect(runWorkflow).toHaveBeenCalledTimes(1)
})
test.each([publish, audit])(
test.each([publish, audit, unpublish])(
'uses persisted active runs even without a local submission',
async (route) => {
const active = await createPendingRun(queue.backend, 'Publish')
@@ -229,6 +238,25 @@ describe('publish/audit admission', () => {
expect(runWorkflow).toHaveBeenCalledTimes(1)
})
test('an Unpublish request consumes the old audit and a fresh Check permits publishing', async () => {
const oldAudit = await createCompletedRun(queue.backend, 'PublishAudit')
const accepted = await unpublish(event({ filePath: 'books/one.books.md' }))
expect(accepted.status).toBe(200)
await completeNextRun(queue.backend)
const rejected = await publish(event({ auditRunId: oldAudit.id }))
expect(rejected.status).toBe(412)
expect(await rejected.json()).toEqual({
message: 'an unpublish was requested after this check; re-check before publishing'
})
expect(runWorkflow).toHaveBeenCalledTimes(1)
vi.setSystemTime(new Date('2026-06-22T00:00:00Z'))
const freshAudit = await createCompletedRun(queue.backend, 'PublishAudit')
expect((await publish(event({ auditRunId: freshAudit.id }))).status).toBe(200)
expect(runWorkflow).toHaveBeenCalledTimes(2)
})
test.each([false, true])(
'persists omitted selection as publish-all with bisync=%s',
async (bisync) => {
@@ -0,0 +1,29 @@
import { json } from '@sveltejs/kit'
import type { RequestHandler } from './$types'
import { unpublishSpec } from '@luzzle/web.jobs/specs'
import { getOpenWorkflow, getOpenWorkflowBackend } from '$lib/server/workflow/index.js'
import { findInFlightPublishRun, withPublishAdmission } from '$lib/server/workflow/publish.js'
export const POST: RequestHandler = async ({ request }) => {
try {
const body = await request.json().catch(() => null)
const filePath = body?.filePath
if (typeof filePath !== 'string') {
return json({ message: 'filePath must be a nonempty string' }, { status: 400 })
}
if (filePath.trim().length === 0) {
return json({ message: 'filePath must be a nonempty string' }, { status: 400 })
}
return await withPublishAdmission(async () => {
const inFlight = await findInFlightPublishRun(getOpenWorkflowBackend())
if (inFlight) return json({ jobId: inFlight.id }, { status: 409 })
const handle = await getOpenWorkflow().runWorkflow(unpublishSpec, { filePath })
return json({ jobId: handle.workflowRun.id })
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return new Response(`Internal server error: ${message}`, { status: 500 })
}
}
@@ -0,0 +1,87 @@
import { beforeEach, expect, test, vi } from 'vitest'
import { POST } from './+server.js'
const mocks = vi.hoisted(() => ({
runWorkflow: vi.fn(),
listWorkflowRuns: vi.fn()
}))
vi.mock('$lib/server/workflow/index.js', () => ({
getOpenWorkflow: () => ({ runWorkflow: mocks.runWorkflow }),
getOpenWorkflowBackend: () => ({ listWorkflowRuns: mocks.listWorkflowRuns })
}))
beforeEach(() => {
vi.resetAllMocks()
mocks.listWorkflowRuns.mockResolvedValue({ data: [] })
mocks.runWorkflow.mockResolvedValue({ workflowRun: { id: 'unpublish-1' } })
})
function event(body: unknown) {
return {
request: new Request('http://localhost/api/admin/unpublish', {
method: 'POST',
body: JSON.stringify(body)
})
} as Parameters<typeof POST>[0]
}
test('queues exactly one file without decoding it or accepting publish options', async () => {
const filePath = 'books/源 #?%.books.md'
const response = await POST(event({ filePath, bisync: true, filePaths: ['other.books.md'] }))
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ jobId: 'unpublish-1' })
expect(mocks.runWorkflow).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ name: 'Unpublish' }),
{ filePath }
)
})
test.each([
{ body: null },
{ body: {} },
{ body: { filePath: ['books/one.books.md'] } },
{ body: { filePath: '' } },
{ body: { filePath: ' ' } }
])('rejects invalid input before admission: $body', async ({ body }) => {
const response = await POST(event(body))
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ message: 'filePath must be a nonempty string' })
expect(mocks.listWorkflowRuns).not.toHaveBeenCalled()
expect(mocks.runWorkflow).not.toHaveBeenCalled()
})
test('rejects malformed JSON without queueing work', async () => {
const request = new Request('http://localhost/api/admin/unpublish', {
method: 'POST',
body: '{'
})
const response = await POST({ request } as Parameters<typeof POST>[0])
expect(response.status).toBe(400)
expect(mocks.runWorkflow).not.toHaveBeenCalled()
})
test.each(['Publish', 'PublishAudit', 'Unpublish'])(
'does not enqueue when a %s operation is active',
async (name) => {
mocks.listWorkflowRuns.mockImplementation(
async ({ workflowName }: { workflowName: string }) => {
if (workflowName === name) return { data: [{ id: 'busy-1', status: 'running' }] }
return { data: [] }
}
)
const response = await POST(event({ filePath: 'books/one.books.md' }))
expect(response.status).toBe(409)
expect(await response.json()).toEqual({ jobId: 'busy-1' })
expect(mocks.runWorkflow).not.toHaveBeenCalled()
}
)
test('reports enqueue errors and releases admission for a later request', async () => {
mocks.runWorkflow.mockRejectedValueOnce(new Error('queue unavailable'))
const failed = await POST(event({ filePath: 'books/one.books.md' }))
expect(failed.status).toBe(500)
expect(await failed.text()).toContain('queue unavailable')
const accepted = await POST(event({ filePath: 'books/one.books.md' }))
expect(accepted.status).toBe(200)
expect(mocks.runWorkflow).toHaveBeenCalledTimes(2)
})
+1
View File
@@ -46,6 +46,7 @@ test.each([
'/admin/piece/[...path]/preview/[jobId]',
'/admin/open/[piece]/[slug]',
'/api/admin/generate',
'/api/admin/unpublish',
'/api/admin/publish/[id]/stream',
'/api/admin/preview/[id]/stream'
])('%s has an auth-grouped route ID without changing its public path', (path) => {