[web] admit and report unpublish jobs #170
No files matched your search
@@ -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) =>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user