feat(api): add contract-safe jobs read endpoints
This commit is contained in:
@@ -0,0 +1,296 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
|
||||||
|
import { JobQueryError, JobQueryService } from './job-query-service.js';
|
||||||
|
|
||||||
|
const t1 = '2026-07-17T10:00:00.000Z';
|
||||||
|
const t2 = '2026-07-17T11:00:00.000Z';
|
||||||
|
|
||||||
|
function setup(): { db: Database.Database; subject: JobQueryService } {
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
migrateDatabase(db);
|
||||||
|
return { db, subject: new JobQueryService(db) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertJob(
|
||||||
|
db: Database.Database,
|
||||||
|
values: Partial<Record<'id' | 'root' | 'operation' | 'status' | 'created', unknown>> = {},
|
||||||
|
): void {
|
||||||
|
const id = values.id ?? 'job-1';
|
||||||
|
const root = Object.prototype.hasOwnProperty.call(values, 'root') ? values.root : id;
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO jobs
|
||||||
|
(id, root_job_id, operation_id, risk_level, status, requested_by, request_id,
|
||||||
|
parameters_digest, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, 'R1', ?, 'actor', 'request-1', 'digest', ?, ?)`,
|
||||||
|
).run(
|
||||||
|
id,
|
||||||
|
root,
|
||||||
|
values.operation ?? 'op.one',
|
||||||
|
values.status ?? 'succeeded',
|
||||||
|
values.created ?? t1,
|
||||||
|
values.created ?? t1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertItem(db: Database.Database, overrides: Record<string, unknown> = {}): void {
|
||||||
|
const row = {
|
||||||
|
id: 'item-1',
|
||||||
|
job: 'job-1',
|
||||||
|
instance: 'instance-1',
|
||||||
|
status: 'succeeded',
|
||||||
|
source: null,
|
||||||
|
error: null,
|
||||||
|
created: t1,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO job_items
|
||||||
|
(id, job_id, instance_id, attempt_number, status, error_json, source_job_item_id,
|
||||||
|
created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?)`,
|
||||||
|
).run(row.id, row.job, row.instance, row.status, row.error, row.source, row.created, row.created);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertAttempt(db: Database.Database, overrides: Record<string, unknown> = {}): void {
|
||||||
|
const row = {
|
||||||
|
id: 'attempt-1',
|
||||||
|
job: 'job-1',
|
||||||
|
status: 'succeeded',
|
||||||
|
started: t1,
|
||||||
|
finished: t2,
|
||||||
|
created: t1,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO job_attempts (id, job_id, status, started_at, finished_at, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
).run(row.id, row.job, row.status, row.started, row.finished, row.created);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectCode(action: () => unknown, code: string): void {
|
||||||
|
try {
|
||||||
|
action();
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(JobQueryError);
|
||||||
|
expect((error as JobQueryError).code).toBe(code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error('expected JobQueryError');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('JobQueryService', () => {
|
||||||
|
it.each(['queued', 'running', 'cancelling'])(
|
||||||
|
'projects only terminal items while a %s job is active and retains its running attempt',
|
||||||
|
(status) => {
|
||||||
|
const { db, subject } = setup();
|
||||||
|
insertJob(db, { status });
|
||||||
|
insertItem(db, { id: 'terminal', instance: 'done', status: 'succeeded' });
|
||||||
|
insertItem(db, { id: 'internal', instance: 'pending', status: 'running' });
|
||||||
|
insertAttempt(db, { status: 'running', finished: null });
|
||||||
|
|
||||||
|
expect(subject.get('job-1')).toMatchObject({
|
||||||
|
status,
|
||||||
|
items: [{ id: 'terminal', targetId: 'done', state: 'succeeded' }],
|
||||||
|
attempts: [{ id: 'attempt-1', state: 'running', startedAt: t1 }],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('returns the exact frozen projection with deterministic child ordering', () => {
|
||||||
|
const { db, subject } = setup();
|
||||||
|
insertJob(db);
|
||||||
|
insertItem(db, { id: 'item-z', instance: 'target-z' });
|
||||||
|
insertItem(db, { id: 'item-a', instance: 'target-a', source: 'item-z' });
|
||||||
|
insertAttempt(db, { id: 'attempt-z', started: t1 });
|
||||||
|
insertAttempt(db, { id: 'attempt-a', started: t1, status: 'running', finished: null });
|
||||||
|
|
||||||
|
expect(subject.get('job-1')).toEqual({
|
||||||
|
id: 'job-1',
|
||||||
|
operationId: 'op.one',
|
||||||
|
status: 'succeeded',
|
||||||
|
rootJobId: 'job-1',
|
||||||
|
items: [
|
||||||
|
{ id: 'item-a', targetId: 'target-a', state: 'succeeded', sourceJobItemId: 'item-z' },
|
||||||
|
{ id: 'item-z', targetId: 'target-z', state: 'succeeded' },
|
||||||
|
],
|
||||||
|
attempts: [
|
||||||
|
{ id: 'attempt-a', state: 'running', startedAt: t1 },
|
||||||
|
{ id: 'attempt-z', state: 'succeeded', startedAt: t1, finishedAt: t2 },
|
||||||
|
],
|
||||||
|
createdAt: t1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paginates in SQL with filters and an ascending id tie-break independent of direction', () => {
|
||||||
|
const { db, subject } = setup();
|
||||||
|
insertJob(db, { id: 'z', operation: 'same', created: t2 });
|
||||||
|
insertJob(db, { id: 'a', operation: 'same', created: t2 });
|
||||||
|
insertJob(db, { id: 'other', operation: 'other', created: t1 });
|
||||||
|
insertItem(db, { id: 'z-item', job: 'z', instance: 'wanted' });
|
||||||
|
insertItem(db, { id: 'a-item', job: 'a', instance: 'wanted' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
subject.list({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 1,
|
||||||
|
sort: 'createdAt',
|
||||||
|
direction: 'desc',
|
||||||
|
operationId: 'same',
|
||||||
|
status: 'succeeded',
|
||||||
|
rootJobId: 'z',
|
||||||
|
instanceId: 'wanted',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
items: [expect.objectContaining({ id: 'z' })],
|
||||||
|
page: { page: 1, pageSize: 1, total: 1 },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
subject.list({ sort: 'createdAt', direction: 'desc' }).items.map(({ id }) => id),
|
||||||
|
).toEqual(['a', 'z', 'other']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports not found without exposing database details', () => {
|
||||||
|
const { subject } = setup();
|
||||||
|
expect(() => subject.get('missing')).toThrow('Job was not found');
|
||||||
|
expectCode(() => subject.get('missing'), 'NOT_FOUND');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ page: 0 }, 'page'],
|
||||||
|
[{ page: 1.5 }, 'page'],
|
||||||
|
[{ pageSize: 0 }, 'pageSize'],
|
||||||
|
[{ pageSize: 101 }, 'pageSize'],
|
||||||
|
[{ sort: 'id' }, 'sort'],
|
||||||
|
[{ direction: 'sideways' }, 'direction'],
|
||||||
|
])('rejects invalid query input: %j', (query, field) => {
|
||||||
|
const { subject } = setup();
|
||||||
|
expect(() => subject.list(query as never)).toThrow(new RegExp(field, 'i'));
|
||||||
|
expectCode(() => subject.list(query as never), 'VALIDATION_FAILED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
'job status',
|
||||||
|
() => {
|
||||||
|
const x = setup();
|
||||||
|
insertJob(x.db, { status: 'invented' });
|
||||||
|
return x;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'missing root',
|
||||||
|
() => {
|
||||||
|
const x = setup();
|
||||||
|
insertJob(x.db, { root: null });
|
||||||
|
return x;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'running item',
|
||||||
|
() => {
|
||||||
|
const x = setup();
|
||||||
|
insertJob(x.db);
|
||||||
|
insertItem(x.db, { status: 'running' });
|
||||||
|
return x;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'attempt state',
|
||||||
|
() => {
|
||||||
|
const x = setup();
|
||||||
|
insertJob(x.db);
|
||||||
|
x.db.pragma('ignore_check_constraints = ON');
|
||||||
|
insertAttempt(x.db, { status: 'queued' });
|
||||||
|
return x;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'timestamp',
|
||||||
|
() => {
|
||||||
|
const x = setup();
|
||||||
|
insertJob(x.db, { created: '2026-07-17T10:00:00Z' });
|
||||||
|
return x;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])('fails the whole selection as UNREPRESENTABLE for invalid persisted %s', (_name, make) => {
|
||||||
|
const { subject } = make();
|
||||||
|
expectCode(() => subject.list(), 'UNREPRESENTABLE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
'items',
|
||||||
|
(db: Database.Database, count: number) => {
|
||||||
|
const insert = db.prepare(
|
||||||
|
`INSERT INTO job_items
|
||||||
|
(id, job_id, instance_id, attempt_number, status, created_at, updated_at)
|
||||||
|
VALUES (?, 'job-1', 'instance-1', ?, 'succeeded', ?, ?)`,
|
||||||
|
);
|
||||||
|
db.transaction(() => {
|
||||||
|
for (let index = 1; index <= count; index += 1)
|
||||||
|
insert.run(`item-${index}`, index, t1, t1);
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'attempts',
|
||||||
|
(db: Database.Database, count: number) => {
|
||||||
|
const insert = db.prepare(
|
||||||
|
`INSERT INTO job_attempts (id, job_id, status, started_at, finished_at, created_at)
|
||||||
|
VALUES (?, 'job-1', 'succeeded', ?, ?, ?)`,
|
||||||
|
);
|
||||||
|
db.transaction(() => {
|
||||||
|
for (let index = 1; index <= count; index += 1)
|
||||||
|
insert.run(`attempt-${index}`, t1, t2, t1);
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])('bounds hydrated child %s at 1000 rows per selected job', (_name, populate) => {
|
||||||
|
const atBound = setup();
|
||||||
|
insertJob(atBound.db);
|
||||||
|
populate(atBound.db, 1000);
|
||||||
|
expect(atBound.subject.get('job-1')).toBeDefined();
|
||||||
|
|
||||||
|
const overBound = setup();
|
||||||
|
insertJob(overBound.db);
|
||||||
|
populate(overBound.db, 1001);
|
||||||
|
expectCode(() => overBound.subject.get('job-1'), 'UNREPRESENTABLE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits only a closed redacted item error and omits malformed optional errors', () => {
|
||||||
|
const { db, subject } = setup();
|
||||||
|
insertJob(db);
|
||||||
|
insertItem(db, {
|
||||||
|
id: 'good',
|
||||||
|
instance: 'instance-good',
|
||||||
|
error: JSON.stringify({
|
||||||
|
type: 'https://evil/secret',
|
||||||
|
title: 'database password leaked',
|
||||||
|
status: 502,
|
||||||
|
detail: 'password=secret',
|
||||||
|
code: 'UPSTREAM_FAILED',
|
||||||
|
requestId: 'req-safe',
|
||||||
|
extra: 'secret',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
insertItem(db, { id: 'bad', instance: 'instance-bad', error: '{bad json' });
|
||||||
|
expect(subject.get('job-1').items).toEqual([
|
||||||
|
{ id: 'bad', targetId: 'instance-bad', state: 'succeeded' },
|
||||||
|
{
|
||||||
|
id: 'good',
|
||||||
|
targetId: 'instance-good',
|
||||||
|
state: 'succeeded',
|
||||||
|
error: {
|
||||||
|
type: 'about:blank',
|
||||||
|
title: 'Job item failed',
|
||||||
|
status: 502,
|
||||||
|
detail: 'The job item did not complete successfully.',
|
||||||
|
code: 'UPSTREAM_FAILED',
|
||||||
|
requestId: 'req-safe',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import {
|
||||||
|
ATTEMPT_STATUSES,
|
||||||
|
JOB_ITEM_TERMINAL_STATES,
|
||||||
|
JOB_STATUSES,
|
||||||
|
type Attempt,
|
||||||
|
type Job,
|
||||||
|
type JobItem,
|
||||||
|
type JobPage,
|
||||||
|
type JobPageQuery,
|
||||||
|
type ProblemDetails,
|
||||||
|
} from '@multi-simadmin/contracts';
|
||||||
|
|
||||||
|
export type JobQueryErrorCode = 'VALIDATION_FAILED' | 'NOT_FOUND' | 'UNREPRESENTABLE';
|
||||||
|
|
||||||
|
export class JobQueryError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: JobQueryErrorCode,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'JobQueryError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JobRow {
|
||||||
|
id: unknown;
|
||||||
|
operation_id: unknown;
|
||||||
|
status: unknown;
|
||||||
|
retry_of_job_id: unknown;
|
||||||
|
root_job_id: unknown;
|
||||||
|
created_at: unknown;
|
||||||
|
}
|
||||||
|
interface ItemRow {
|
||||||
|
id: unknown;
|
||||||
|
job_id: unknown;
|
||||||
|
instance_id: unknown;
|
||||||
|
status: unknown;
|
||||||
|
source_job_item_id: unknown;
|
||||||
|
error_json: unknown;
|
||||||
|
}
|
||||||
|
interface AttemptRow {
|
||||||
|
id: unknown;
|
||||||
|
job_id: unknown;
|
||||||
|
status: unknown;
|
||||||
|
started_at: unknown;
|
||||||
|
finished_at: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const JOB_COLUMNS = 'id, operation_id, status, retry_of_job_id, root_job_id, created_at';
|
||||||
|
const CANONICAL_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
|
||||||
|
const SAFE_ERROR_CODES = /^[A-Z][A-Z0-9_]{0,63}$/u;
|
||||||
|
const SAFE_REQUEST_IDS = /^[^\x00-\x1f\x7f]{1,200}$/u;
|
||||||
|
const MAX_CHILD_ROWS_PER_JOB = 1000;
|
||||||
|
const ACTIVE_JOB_STATUSES = new Set(['queued', 'running', 'cancelling']);
|
||||||
|
|
||||||
|
function fail(message: string): never {
|
||||||
|
throw new JobQueryError('UNREPRESENTABLE', message);
|
||||||
|
}
|
||||||
|
function validation(message: string): never {
|
||||||
|
throw new JobQueryError('VALIDATION_FAILED', message);
|
||||||
|
}
|
||||||
|
function requiredString(value: unknown, field: string): string {
|
||||||
|
if (typeof value !== 'string' || value.length === 0) fail(`Persisted job ${field} is invalid`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function optionalString(value: unknown, field: string): string | undefined {
|
||||||
|
if (value === null) return undefined;
|
||||||
|
return requiredString(value, field);
|
||||||
|
}
|
||||||
|
function timestamp(value: unknown, field: string): string {
|
||||||
|
const result = requiredString(value, field);
|
||||||
|
if (!CANONICAL_TIMESTAMP.test(result)) fail(`Persisted job ${field} is not canonical`);
|
||||||
|
const parsed = new Date(result);
|
||||||
|
if (!Number.isFinite(parsed.getTime()) || parsed.toISOString() !== result)
|
||||||
|
fail(`Persisted job ${field} is invalid`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function positiveInteger(value: unknown, fallback: number, maximum: number, field: string): number {
|
||||||
|
const result = value ?? fallback;
|
||||||
|
if (!Number.isSafeInteger(result) || (result as number) < 1 || (result as number) > maximum)
|
||||||
|
validation(`${field} must be an integer from 1 to ${maximum}`);
|
||||||
|
return result as number;
|
||||||
|
}
|
||||||
|
function freeze<T extends object>(value: T): Readonly<T> {
|
||||||
|
return Object.freeze(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeError(serialized: unknown): ProblemDetails | undefined {
|
||||||
|
if (serialized === null) return undefined;
|
||||||
|
if (typeof serialized !== 'string') return undefined;
|
||||||
|
let value: unknown;
|
||||||
|
try {
|
||||||
|
value = JSON.parse(serialized);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
|
||||||
|
const row = value as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
typeof row.status !== 'number' ||
|
||||||
|
!Number.isInteger(row.status) ||
|
||||||
|
row.status < 400 ||
|
||||||
|
row.status > 599 ||
|
||||||
|
typeof row.code !== 'string' ||
|
||||||
|
!SAFE_ERROR_CODES.test(row.code) ||
|
||||||
|
typeof row.requestId !== 'string' ||
|
||||||
|
!SAFE_REQUEST_IDS.test(row.requestId)
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
return freeze({
|
||||||
|
type: 'about:blank',
|
||||||
|
title: 'Job item failed',
|
||||||
|
status: row.status,
|
||||||
|
detail: 'The job item did not complete successfully.',
|
||||||
|
code: row.code,
|
||||||
|
requestId: row.requestId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JobQueryService {
|
||||||
|
constructor(private readonly db: Database.Database) {}
|
||||||
|
|
||||||
|
get(id: string): Job {
|
||||||
|
if (typeof id !== 'string' || id.length === 0) validation('id must be a non-empty string');
|
||||||
|
const row = this.db.prepare(`SELECT ${JOB_COLUMNS} FROM jobs WHERE id = ?`).get(id) as
|
||||||
|
| JobRow
|
||||||
|
| undefined;
|
||||||
|
if (!row) throw new JobQueryError('NOT_FOUND', 'Job was not found');
|
||||||
|
return this.project([row])[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
list(query: JobPageQuery = {}): JobPage {
|
||||||
|
const pageSize = positiveInteger(query.pageSize, 25, 100, 'pageSize');
|
||||||
|
const maximumPage = Math.floor(Number.MAX_SAFE_INTEGER / pageSize) + 1;
|
||||||
|
const page = positiveInteger(query.page, 1, maximumPage, 'page');
|
||||||
|
const sort = query.sort ?? 'createdAt';
|
||||||
|
if (sort !== 'createdAt' && sort !== 'status' && sort !== 'operationId')
|
||||||
|
validation('sort is invalid');
|
||||||
|
const direction = query.direction ?? 'asc';
|
||||||
|
if (direction !== 'asc' && direction !== 'desc') validation('direction is invalid');
|
||||||
|
|
||||||
|
const clauses: string[] = [];
|
||||||
|
const parameters: unknown[] = [];
|
||||||
|
const filter = (column: string, value: unknown): void => {
|
||||||
|
if (value === undefined) return;
|
||||||
|
if (typeof value !== 'string' || value.length === 0)
|
||||||
|
validation(`${column} filter is invalid`);
|
||||||
|
clauses.push(`${column} = ?`);
|
||||||
|
parameters.push(value);
|
||||||
|
};
|
||||||
|
filter('j.status', query.status);
|
||||||
|
filter('j.operation_id', query.operationId);
|
||||||
|
filter('j.root_job_id', query.rootJobId);
|
||||||
|
if (query.instanceId !== undefined) {
|
||||||
|
if (typeof query.instanceId !== 'string' || query.instanceId.length === 0)
|
||||||
|
validation('instanceId filter is invalid');
|
||||||
|
clauses.push(
|
||||||
|
'EXISTS (SELECT 1 FROM job_items ji WHERE ji.job_id = j.id AND ji.instance_id = ?)',
|
||||||
|
);
|
||||||
|
parameters.push(query.instanceId);
|
||||||
|
}
|
||||||
|
const where = clauses.length ? ` WHERE ${clauses.join(' AND ')}` : '';
|
||||||
|
const totalRow = this.db
|
||||||
|
.prepare(`SELECT COUNT(*) AS total FROM jobs j${where}`)
|
||||||
|
.get(...parameters) as { total: unknown } | undefined;
|
||||||
|
if (!totalRow || typeof totalRow.total !== 'number' || !Number.isSafeInteger(totalRow.total))
|
||||||
|
fail('Persisted job count is invalid');
|
||||||
|
|
||||||
|
const sortColumn = {
|
||||||
|
createdAt: 'j.created_at',
|
||||||
|
status: 'j.status',
|
||||||
|
operationId: 'j.operation_id',
|
||||||
|
}[sort];
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const rows = this.db
|
||||||
|
.prepare(
|
||||||
|
`SELECT ${JOB_COLUMNS.split(', ')
|
||||||
|
.map((column) => `j.${column}`)
|
||||||
|
.join(', ')} FROM jobs j${where}
|
||||||
|
ORDER BY ${sortColumn} ${direction.toUpperCase()}, j.id ASC LIMIT ? OFFSET ?`,
|
||||||
|
)
|
||||||
|
.all(...parameters, pageSize, offset) as JobRow[];
|
||||||
|
return freeze({
|
||||||
|
items: freeze(this.project(rows)),
|
||||||
|
page: freeze({ page, pageSize, total: totalRow.total }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private project(rows: JobRow[]): Job[] {
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
const statuses = new Map<string, Job['status']>();
|
||||||
|
const ids = rows.map((row) => {
|
||||||
|
const id = requiredString(row.id, 'id');
|
||||||
|
const status = requiredString(row.status, 'status');
|
||||||
|
if (!(JOB_STATUSES as readonly string[]).includes(status))
|
||||||
|
fail('Persisted job status is invalid');
|
||||||
|
statuses.set(id, status as Job['status']);
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
const placeholders = ids.map(() => '?').join(', ');
|
||||||
|
for (const table of ['job_items', 'job_attempts'] as const) {
|
||||||
|
const counts = this.db
|
||||||
|
.prepare(
|
||||||
|
`SELECT job_id, COUNT(*) AS count FROM ${table}
|
||||||
|
WHERE job_id IN (${placeholders}) GROUP BY job_id HAVING COUNT(*) > ?`,
|
||||||
|
)
|
||||||
|
.all(...ids, MAX_CHILD_ROWS_PER_JOB) as { job_id: unknown; count: unknown }[];
|
||||||
|
if (counts.length > 0) fail('Persisted job child collection exceeds the resource bound');
|
||||||
|
}
|
||||||
|
const items = this.db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, job_id, instance_id, status, source_job_item_id, error_json
|
||||||
|
FROM job_items WHERE job_id IN (${placeholders}) ORDER BY job_id ASC, id ASC`,
|
||||||
|
)
|
||||||
|
.all(...ids) as ItemRow[];
|
||||||
|
const attempts = this.db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, job_id, status, started_at, finished_at
|
||||||
|
FROM job_attempts WHERE job_id IN (${placeholders}) ORDER BY job_id ASC, started_at ASC, id ASC`,
|
||||||
|
)
|
||||||
|
.all(...ids) as AttemptRow[];
|
||||||
|
const itemMap = new Map<string, JobItem[]>();
|
||||||
|
const attemptMap = new Map<string, Attempt[]>();
|
||||||
|
|
||||||
|
for (const row of items) {
|
||||||
|
const jobId = requiredString(row.job_id, 'item job_id');
|
||||||
|
const state = requiredString(row.status, 'item status');
|
||||||
|
if (!(JOB_ITEM_TERMINAL_STATES as readonly string[]).includes(state)) {
|
||||||
|
if (ACTIVE_JOB_STATUSES.has(statuses.get(jobId) ?? '')) continue;
|
||||||
|
fail('Persisted job item is not terminal');
|
||||||
|
}
|
||||||
|
const sourceJobItemId = optionalString(row.source_job_item_id, 'item source_job_item_id');
|
||||||
|
const error = safeError(row.error_json);
|
||||||
|
const item: JobItem = freeze({
|
||||||
|
id: requiredString(row.id, 'item id'),
|
||||||
|
targetId: requiredString(row.instance_id, 'item instance_id'),
|
||||||
|
state: state as JobItem['state'],
|
||||||
|
...(sourceJobItemId === undefined ? {} : { sourceJobItemId }),
|
||||||
|
...(error === undefined ? {} : { error }),
|
||||||
|
});
|
||||||
|
const values = itemMap.get(jobId) ?? [];
|
||||||
|
values.push(item);
|
||||||
|
itemMap.set(jobId, values);
|
||||||
|
}
|
||||||
|
for (const row of attempts) {
|
||||||
|
const jobId = requiredString(row.job_id, 'attempt job_id');
|
||||||
|
const state = requiredString(row.status, 'attempt status');
|
||||||
|
if (!(ATTEMPT_STATUSES as readonly string[]).includes(state))
|
||||||
|
fail('Persisted attempt status is invalid');
|
||||||
|
const finishedAt =
|
||||||
|
row.finished_at === null ? undefined : timestamp(row.finished_at, 'attempt finished_at');
|
||||||
|
const attempt: Attempt = freeze({
|
||||||
|
id: requiredString(row.id, 'attempt id'),
|
||||||
|
state: state as Attempt['state'],
|
||||||
|
startedAt: timestamp(row.started_at, 'attempt started_at'),
|
||||||
|
...(finishedAt === undefined ? {} : { finishedAt }),
|
||||||
|
});
|
||||||
|
const values = attemptMap.get(jobId) ?? [];
|
||||||
|
values.push(attempt);
|
||||||
|
attemptMap.set(jobId, values);
|
||||||
|
}
|
||||||
|
return rows.map((row) => {
|
||||||
|
const id = requiredString(row.id, 'id');
|
||||||
|
const status = statuses.get(id)!;
|
||||||
|
const retryOfJobId = optionalString(row.retry_of_job_id, 'retry_of_job_id');
|
||||||
|
return freeze({
|
||||||
|
id,
|
||||||
|
operationId: requiredString(row.operation_id, 'operation_id'),
|
||||||
|
status: status as Job['status'],
|
||||||
|
...(retryOfJobId === undefined ? {} : { retryOfJobId }),
|
||||||
|
rootJobId: requiredString(row.root_job_id, 'root_job_id'),
|
||||||
|
items: freeze(itemMap.get(id) ?? []),
|
||||||
|
attempts: freeze(attemptMap.get(id) ?? []),
|
||||||
|
createdAt: timestamp(row.created_at, 'created_at'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,9 @@ describe('buildControlPlaneApp', () => {
|
|||||||
expect(response.statusCode).toBe(401);
|
expect(response.statusCode).toBe(401);
|
||||||
expect(response.headers['content-type']).toContain('application/problem+json');
|
expect(response.headers['content-type']).toContain('application/problem+json');
|
||||||
expect(response.json()).toMatchObject({ status: 401, code: 'UNAUTHORIZED' });
|
expect(response.json()).toMatchObject({ status: 401, code: 'UNAUTHORIZED' });
|
||||||
|
const jobs = await app.inject({ method: 'GET', url: '/api/v1/jobs' });
|
||||||
|
expect(jobs.statusCode).toBe(200);
|
||||||
|
expect(jobs.json()).toEqual({ items: [], page: { page: 1, pageSize: 25, total: 0 } });
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import { DeleteInstanceOperation } from './application/operations/delete-instanc
|
|||||||
import type { SecretStore } from './infrastructure/secrets/secret-store.js';
|
import type { SecretStore } from './infrastructure/secrets/secret-store.js';
|
||||||
import { registerInstanceRoutes } from './interface/http/instance-routes.js';
|
import { registerInstanceRoutes } from './interface/http/instance-routes.js';
|
||||||
import { registerEventRoutes } from './interface/http/event-routes.js';
|
import { registerEventRoutes } from './interface/http/event-routes.js';
|
||||||
|
import { JobQueryService } from './application/jobs/job-query-service.js';
|
||||||
|
import { registerJobRoutes } from './interface/http/job-routes.js';
|
||||||
|
|
||||||
export interface SafeControlPlaneUpstream extends ConnectionTransport {
|
export interface SafeControlPlaneUpstream extends ConnectionTransport {
|
||||||
request: UpstreamSessionClientOptions['request'];
|
request: UpstreamSessionClientOptions['request'];
|
||||||
@@ -42,6 +44,7 @@ export interface ControlPlaneApp extends FastifyInstance {
|
|||||||
}
|
}
|
||||||
export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlaneApp {
|
export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlaneApp {
|
||||||
const eventJournal = new EventJournal(options.db);
|
const eventJournal = new EventJournal(options.db);
|
||||||
|
const jobs = new JobQueryService(options.db);
|
||||||
const instances = options.now
|
const instances = options.now
|
||||||
? new InstanceService({ db: options.db, store: options.store, now: options.now })
|
? new InstanceService({ db: options.db, store: options.store, now: options.now })
|
||||||
: new InstanceService({ db: options.db, store: options.store });
|
: new InstanceService({ db: options.db, store: options.store });
|
||||||
@@ -86,6 +89,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
|
|||||||
registerDeletionPreparationRoute: false,
|
registerDeletionPreparationRoute: false,
|
||||||
});
|
});
|
||||||
registerOperationRoutes(app, operationCatalogRegistry, secureExecution, deletion);
|
registerOperationRoutes(app, operationCatalogRegistry, secureExecution, deletion);
|
||||||
|
registerJobRoutes(app, { jobs });
|
||||||
registerEventRoutes(app, {
|
registerEventRoutes(app, {
|
||||||
journal: eventJournal,
|
journal: eventJournal,
|
||||||
...(options.authenticateEventStream
|
...(options.authenticateEventStream
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { buildApp } from '../../app.js';
|
||||||
|
import { JobQueryService } from '../../application/jobs/job-query-service.js';
|
||||||
|
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
|
||||||
|
import { registerJobRoutes } from './job-routes.js';
|
||||||
|
|
||||||
|
const dbs: Database.Database[] = [];
|
||||||
|
const timestamp = '2026-07-17T10:00:00.000Z';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const db of dbs.splice(0)) db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.pragma('foreign_keys=ON');
|
||||||
|
migrateDatabase(db);
|
||||||
|
dbs.push(db);
|
||||||
|
const jobs = new JobQueryService(db);
|
||||||
|
const app = buildApp({ registerRoutes: (scope) => registerJobRoutes(scope, { jobs }) });
|
||||||
|
return { app, db, jobs };
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertJob(db: Database.Database, id = 'job-1', status = 'succeeded'): void {
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO jobs
|
||||||
|
(id, root_job_id, operation_id, risk_level, status, requested_by, request_id,
|
||||||
|
parameters_digest, created_at, updated_at)
|
||||||
|
VALUES (?, ?, 'network.register-auto', 'R1', ?, 'actor', 'request-1',
|
||||||
|
'digest', ?, ?)`,
|
||||||
|
).run(id, id, status, timestamp, timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exactProblem = (
|
||||||
|
response: { headers: Record<string, string | string[] | number | undefined>; json(): unknown },
|
||||||
|
values: { title: string; status: number; code: string; detail: string },
|
||||||
|
) => {
|
||||||
|
expect(response.headers['content-type']).toContain('application/problem+json');
|
||||||
|
expect(response.headers['x-request-id']).toEqual(expect.any(String));
|
||||||
|
expect(response.json()).toEqual({
|
||||||
|
type: 'about:blank',
|
||||||
|
...values,
|
||||||
|
requestId: response.headers['x-request-id'],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('job HTTP read routes', () => {
|
||||||
|
it('serves active jobs without exposing internal nonterminal items', async () => {
|
||||||
|
const { app, db } = fixture();
|
||||||
|
insertJob(db, 'job-1', 'running');
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO job_items
|
||||||
|
(id, job_id, instance_id, attempt_number, status, created_at, updated_at)
|
||||||
|
VALUES ('item-pending', 'job-1', 'instance-1', 1, 'running', ?, ?)`,
|
||||||
|
).run(timestamp, timestamp);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO job_attempts (id, job_id, status, started_at, finished_at, created_at)
|
||||||
|
VALUES ('attempt-1', 'job-1', 'running', ?, NULL, ?)`,
|
||||||
|
).run(timestamp, timestamp);
|
||||||
|
|
||||||
|
const response = await app.inject({ method: 'GET', url: '/api/v1/jobs/job-1' });
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
status: 'running',
|
||||||
|
items: [],
|
||||||
|
attempts: [{ id: 'attempt-1', state: 'running', startedAt: timestamp }],
|
||||||
|
});
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps an overbound child collection to the stable redacted 503 problem', async () => {
|
||||||
|
const { app, db } = fixture();
|
||||||
|
insertJob(db);
|
||||||
|
const insert = db.prepare(
|
||||||
|
`INSERT INTO job_attempts (id, job_id, status, started_at, finished_at, created_at)
|
||||||
|
VALUES (?, 'job-1', 'succeeded', ?, ?, ?)`,
|
||||||
|
);
|
||||||
|
db.transaction(() => {
|
||||||
|
for (let index = 1; index <= 1001; index += 1)
|
||||||
|
insert.run(`attempt-${index}`, timestamp, timestamp, timestamp);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const response = await app.inject({ method: 'GET', url: '/api/v1/jobs/job-1' });
|
||||||
|
expect(response.statusCode).toBe(503);
|
||||||
|
exactProblem(response, {
|
||||||
|
title: 'Service Unavailable',
|
||||||
|
status: 503,
|
||||||
|
code: 'UNREPRESENTABLE',
|
||||||
|
detail: 'The requested job data is temporarily unavailable.',
|
||||||
|
});
|
||||||
|
expect(response.body).not.toContain('1000');
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists and gets jobs and supplies the documented list defaults', async () => {
|
||||||
|
const { app, db, jobs } = fixture();
|
||||||
|
insertJob(db);
|
||||||
|
const listCall = vi.spyOn(jobs, 'list');
|
||||||
|
|
||||||
|
const list = await app.inject({ method: 'GET', url: '/api/v1/jobs' });
|
||||||
|
expect(list.statusCode).toBe(200);
|
||||||
|
expect(listCall).toHaveBeenCalledWith({ page: 1, pageSize: 25, direction: 'asc' });
|
||||||
|
expect(list.json()).toEqual({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'job-1',
|
||||||
|
operationId: 'network.register-auto',
|
||||||
|
status: 'succeeded',
|
||||||
|
rootJobId: 'job-1',
|
||||||
|
items: [],
|
||||||
|
attempts: [],
|
||||||
|
createdAt: timestamp,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
page: { page: 1, pageSize: 25, total: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const get = await app.inject({ method: 'GET', url: '/api/v1/jobs/job-1' });
|
||||||
|
expect(get.statusCode).toBe(200);
|
||||||
|
expect(get.json()).toMatchObject({ id: 'job-1', status: 'succeeded' });
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses every allowed scalar query exactly', async () => {
|
||||||
|
const { app, jobs } = fixture();
|
||||||
|
const listCall = vi.spyOn(jobs, 'list');
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/jobs?page=2&pageSize=10&sort=operationId&direction=desc&status=failed&operationId=op.one&rootJobId=root-1&instanceId=instance-1',
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(listCall).toHaveBeenCalledWith({
|
||||||
|
page: 2,
|
||||||
|
pageSize: 10,
|
||||||
|
sort: 'operationId',
|
||||||
|
direction: 'desc',
|
||||||
|
status: 'failed',
|
||||||
|
operationId: 'op.one',
|
||||||
|
rootJobId: 'root-1',
|
||||||
|
instanceId: 'instance-1',
|
||||||
|
});
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'unknown=value',
|
||||||
|
'page=1&page=2',
|
||||||
|
'status=failed&status=succeeded',
|
||||||
|
'page=0',
|
||||||
|
'page=01',
|
||||||
|
'page=1.5',
|
||||||
|
'page=9007199254740992',
|
||||||
|
'pageSize=0',
|
||||||
|
'pageSize=101',
|
||||||
|
'sort=',
|
||||||
|
'sort=id',
|
||||||
|
'direction=ASC',
|
||||||
|
'status=complete',
|
||||||
|
'status=',
|
||||||
|
'operationId=',
|
||||||
|
'rootJobId=',
|
||||||
|
'instanceId=',
|
||||||
|
])('rejects invalid, unknown, empty, or repeated query input: %s', async (query) => {
|
||||||
|
const { app } = fixture();
|
||||||
|
const response = await app.inject({ method: 'GET', url: `/api/v1/jobs?${query}` });
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
exactProblem(response, {
|
||||||
|
title: 'Bad Request',
|
||||||
|
status: 400,
|
||||||
|
code: 'VALIDATION_FAILED',
|
||||||
|
detail: 'The jobs query is invalid.',
|
||||||
|
});
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps a missing job to an exact redacted 404 problem', async () => {
|
||||||
|
const { app } = fixture();
|
||||||
|
const response = await app.inject({ method: 'GET', url: '/api/v1/jobs/missing' });
|
||||||
|
expect(response.statusCode).toBe(404);
|
||||||
|
exactProblem(response, {
|
||||||
|
title: 'Not Found',
|
||||||
|
status: 404,
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
detail: 'The requested job does not exist.',
|
||||||
|
});
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps unrepresentable persisted data to an exact stable redacted 503 problem', async () => {
|
||||||
|
const { app, db } = fixture();
|
||||||
|
insertJob(db);
|
||||||
|
db.prepare(`UPDATE jobs SET created_at = 'not-a-secret-but-invalid' WHERE id = 'job-1'`).run();
|
||||||
|
const response = await app.inject({ method: 'GET', url: '/api/v1/jobs/job-1' });
|
||||||
|
expect(response.statusCode).toBe(503);
|
||||||
|
exactProblem(response, {
|
||||||
|
title: 'Service Unavailable',
|
||||||
|
status: 503,
|
||||||
|
code: 'UNREPRESENTABLE',
|
||||||
|
detail: 'The requested job data is temporarily unavailable.',
|
||||||
|
});
|
||||||
|
expect(response.body).not.toContain('not-a-secret-but-invalid');
|
||||||
|
expect(response.body).not.toContain('canonical');
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { JOB_STATUSES, type JobPageQuery, type JobStatus } from '@multi-simadmin/contracts';
|
||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
|
import { JobQueryError, JobQueryService } from '../../application/jobs/job-query-service.js';
|
||||||
|
|
||||||
|
export interface JobRoutesOptions {
|
||||||
|
readonly jobs: JobQueryService;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUERY_KEYS = new Set([
|
||||||
|
'page',
|
||||||
|
'pageSize',
|
||||||
|
'sort',
|
||||||
|
'direction',
|
||||||
|
'status',
|
||||||
|
'operationId',
|
||||||
|
'rootJobId',
|
||||||
|
'instanceId',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const errorResponse = {
|
||||||
|
VALIDATION_FAILED: {
|
||||||
|
status: 400,
|
||||||
|
title: 'Bad Request',
|
||||||
|
detail: 'The jobs query is invalid.',
|
||||||
|
},
|
||||||
|
NOT_FOUND: {
|
||||||
|
status: 404,
|
||||||
|
title: 'Not Found',
|
||||||
|
detail: 'The requested job does not exist.',
|
||||||
|
},
|
||||||
|
UNREPRESENTABLE: {
|
||||||
|
status: 503,
|
||||||
|
title: 'Service Unavailable',
|
||||||
|
detail: 'The requested job data is temporarily unavailable.',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function invalid(): never {
|
||||||
|
throw new JobQueryError('VALIDATION_FAILED', 'Invalid jobs query');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseQuery(query: unknown): JobPageQuery {
|
||||||
|
if (query === null || typeof query !== 'object' || Array.isArray(query)) invalid();
|
||||||
|
const input = query as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(input)) {
|
||||||
|
if (!QUERY_KEYS.has(key)) invalid();
|
||||||
|
if (typeof input[key] !== 'string') invalid();
|
||||||
|
}
|
||||||
|
const scalar = (key: string): string | undefined => {
|
||||||
|
const value = input[key];
|
||||||
|
return value === undefined ? undefined : (value as string);
|
||||||
|
};
|
||||||
|
const integer = (key: 'page' | 'pageSize', fallback: number, maximum: number): number => {
|
||||||
|
const value = scalar(key);
|
||||||
|
if (value === undefined) return fallback;
|
||||||
|
if (!/^[1-9]\d*$/u.test(value)) invalid();
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isSafeInteger(parsed) || parsed > maximum) invalid();
|
||||||
|
return parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageSize = integer('pageSize', 25, 100);
|
||||||
|
const maximumPage = Math.floor(Number.MAX_SAFE_INTEGER / pageSize) + 1;
|
||||||
|
const result: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
sort?: 'createdAt' | 'status' | 'operationId';
|
||||||
|
direction: 'asc' | 'desc';
|
||||||
|
status?: JobStatus;
|
||||||
|
operationId?: string;
|
||||||
|
rootJobId?: string;
|
||||||
|
instanceId?: string;
|
||||||
|
} = {
|
||||||
|
page: integer('page', 1, maximumPage),
|
||||||
|
pageSize,
|
||||||
|
direction: 'asc',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sort = scalar('sort');
|
||||||
|
if (sort !== undefined) {
|
||||||
|
if (sort !== 'createdAt' && sort !== 'status' && sort !== 'operationId') invalid();
|
||||||
|
result.sort = sort;
|
||||||
|
}
|
||||||
|
const direction = scalar('direction');
|
||||||
|
if (direction !== undefined) {
|
||||||
|
if (direction !== 'asc' && direction !== 'desc') invalid();
|
||||||
|
result.direction = direction;
|
||||||
|
}
|
||||||
|
const status = scalar('status');
|
||||||
|
if (status !== undefined) {
|
||||||
|
if (!(JOB_STATUSES as readonly string[]).includes(status)) invalid();
|
||||||
|
result.status = status as JobStatus;
|
||||||
|
}
|
||||||
|
for (const key of ['operationId', 'rootJobId', 'instanceId'] as const) {
|
||||||
|
const value = scalar(key);
|
||||||
|
if (value !== undefined) {
|
||||||
|
if (value.length === 0) invalid();
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerJobRoutes(app: FastifyInstance, options: JobRoutesOptions): void {
|
||||||
|
const handler =
|
||||||
|
<T>(action: (request: FastifyRequest) => T) =>
|
||||||
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
try {
|
||||||
|
return action(request);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof JobQueryError)) throw error;
|
||||||
|
const mapped = errorResponse[error.code];
|
||||||
|
return reply.code(mapped.status).type('application/problem+json').send({
|
||||||
|
type: 'about:blank',
|
||||||
|
title: mapped.title,
|
||||||
|
status: mapped.status,
|
||||||
|
code: error.code,
|
||||||
|
detail: mapped.detail,
|
||||||
|
requestId: request.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/api/v1/jobs',
|
||||||
|
handler((request) => options.jobs.list(parseQuery(request.query))),
|
||||||
|
);
|
||||||
|
app.get(
|
||||||
|
'/api/v1/jobs/:jobId',
|
||||||
|
handler((request) => options.jobs.get((request.params as { jobId: string }).jobId)),
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user