mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 10:32:40 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,654 @@
|
||||
import type {
|
||||
PostgresClient,
|
||||
PostgresPool,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
ApprovalMutationConflictError,
|
||||
ApprovalPolicyFenceConflictError,
|
||||
ApprovalRequestNotFoundError,
|
||||
ApprovalRequestStateConflictError,
|
||||
ApprovalUnavailableError,
|
||||
approvalRequestDigest,
|
||||
approvedActionDispatchDigest,
|
||||
consumeApprovalRequest,
|
||||
decideApprovalRequest,
|
||||
normalizeApprovalRequestRecord,
|
||||
normalizeApprovedActionDispatchRecord,
|
||||
type ApprovalRequestRecord,
|
||||
type ApprovalRequestRepository,
|
||||
type ApprovedActionDispatchRecord,
|
||||
type ConsumeDurableApprovalRequestCommand,
|
||||
type ConsumeDurableApprovalRequestResult,
|
||||
type CreateApprovalRequestCommand,
|
||||
type CreateApprovalRequestResult,
|
||||
type DecideApprovalRequestResult,
|
||||
type DecideDurableApprovalRequestCommand,
|
||||
} from '@qinglong/runtime-core/approved-action';
|
||||
import type {
|
||||
SecurityPolicyFence,
|
||||
SecuritySubject,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
} from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
import {
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES,
|
||||
POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS,
|
||||
configurePostgresDefinitionTransaction,
|
||||
postgresRequiredBoolean,
|
||||
postgresRequiredInteger,
|
||||
postgresRequiredJsonObject,
|
||||
postgresRequiredString,
|
||||
postgresSqlState,
|
||||
rollbackPostgresDefinitionTransaction,
|
||||
} from '../repository/definitionRepositorySupport';
|
||||
import {
|
||||
findPostgresApprovedActionExecution,
|
||||
insertPostgresApprovedActionExecutionBaseline,
|
||||
} from './approvedActionExecutionRepository';
|
||||
import { createApprovedActionExecution } from '@qinglong/runtime-core/approved-action-execution';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
type Queryable = Pick<PostgresPool, 'query'> | Pick<PostgresClient, 'query'>;
|
||||
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
function unavailable(): ApprovalUnavailableError {
|
||||
return new ApprovalUnavailableError();
|
||||
}
|
||||
|
||||
function nullableString(value: unknown): string | null {
|
||||
if (value === null) return null;
|
||||
return postgresRequiredString(value, unavailable);
|
||||
}
|
||||
|
||||
function nullableInteger(value: unknown): number | null {
|
||||
if (value === null) return null;
|
||||
return postgresRequiredInteger(value, unavailable);
|
||||
}
|
||||
|
||||
function same(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function parseRequest(row: Row): Readonly<ApprovalRequestRecord> {
|
||||
try {
|
||||
const request = normalizeApprovalRequestRecord(
|
||||
postgresRequiredJsonObject(
|
||||
row.requestJson,
|
||||
unavailable,
|
||||
) as unknown as ApprovalRequestRecord,
|
||||
);
|
||||
if (
|
||||
approvalRequestDigest(request) !==
|
||||
postgresRequiredString(row.requestDigest, unavailable)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return request;
|
||||
} catch (error) {
|
||||
if (error instanceof ApprovalUnavailableError) throw error;
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
function parseDispatch(row: Row): Readonly<ApprovedActionDispatchRecord> {
|
||||
try {
|
||||
const dispatch = normalizeApprovedActionDispatchRecord(
|
||||
postgresRequiredJsonObject(
|
||||
row.dispatchJson,
|
||||
unavailable,
|
||||
) as unknown as ApprovedActionDispatchRecord,
|
||||
);
|
||||
if (
|
||||
approvedActionDispatchDigest(dispatch) !==
|
||||
postgresRequiredString(row.dispatchDigest, unavailable)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return dispatch;
|
||||
} catch (error) {
|
||||
if (error instanceof ApprovalUnavailableError) throw error;
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
function parseAudit(row: Row): Readonly<SecurityAuditRecord> {
|
||||
try {
|
||||
const subjectType = nullableString(row.subjectType);
|
||||
const subjectId = nullableString(row.subjectId);
|
||||
const fenceProjectVersion = nullableInteger(row.fenceProjectVersion);
|
||||
const reasons = row.reasonsJson;
|
||||
if (!Array.isArray(reasons)) throw unavailable();
|
||||
return normalizeSecurityAuditRecord({
|
||||
eventId: postgresRequiredString(row.eventId, unavailable),
|
||||
requestId: postgresRequiredString(row.requestId, unavailable),
|
||||
operationId: postgresRequiredString(row.operationId, unavailable),
|
||||
projectId: nullableString(row.projectId),
|
||||
subject:
|
||||
subjectType === null || subjectId === null
|
||||
? null
|
||||
: {
|
||||
type: subjectType as SecuritySubject['type'],
|
||||
id: subjectId,
|
||||
},
|
||||
authenticationId: nullableString(row.authenticationId),
|
||||
outcome: postgresRequiredString(
|
||||
row.outcome,
|
||||
unavailable,
|
||||
) as SecurityAuditRecord['outcome'],
|
||||
reasons: reasons as readonly string[],
|
||||
fence:
|
||||
fenceProjectVersion === null
|
||||
? null
|
||||
: {
|
||||
projectVersion: fenceProjectVersion,
|
||||
bindingVersion: nullableInteger(row.fenceBindingVersion),
|
||||
},
|
||||
occurredAtMs: postgresRequiredInteger(row.occurredAtMs, unavailable),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ApprovalUnavailableError) throw error;
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
function mappedError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof ApprovalMutationConflictError ||
|
||||
error instanceof ApprovalPolicyFenceConflictError ||
|
||||
error instanceof ApprovalRequestNotFoundError ||
|
||||
error instanceof ApprovalRequestStateConflictError ||
|
||||
error instanceof ApprovalUnavailableError ||
|
||||
(error instanceof Error &&
|
||||
error.name.startsWith('Approval') &&
|
||||
'code' in error)
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
const state = postgresSqlState(error);
|
||||
if (state === '23503' || state === '23505' || state === '23514') {
|
||||
return new ApprovalMutationConflictError();
|
||||
}
|
||||
return new ApprovalUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function auditMatches(
|
||||
audit: Readonly<SecurityAuditRecord>,
|
||||
expected: Readonly<{
|
||||
operationId: string;
|
||||
projectId: string;
|
||||
subject: Readonly<SecuritySubject>;
|
||||
authenticationId?: string;
|
||||
outcome: SecurityAuditRecord['outcome'];
|
||||
fence: Readonly<SecurityPolicyFence>;
|
||||
}>,
|
||||
): boolean {
|
||||
return (
|
||||
audit.operationId === expected.operationId &&
|
||||
audit.projectId === expected.projectId &&
|
||||
audit.subject?.type === expected.subject.type &&
|
||||
audit.subject.id === expected.subject.id &&
|
||||
(expected.authenticationId === undefined ||
|
||||
audit.authenticationId === expected.authenticationId) &&
|
||||
audit.outcome === expected.outcome &&
|
||||
audit.fence?.projectVersion === expected.fence.projectVersion &&
|
||||
audit.fence.bindingVersion === expected.fence.bindingVersion
|
||||
);
|
||||
}
|
||||
|
||||
async function requestById(
|
||||
queryable: Queryable,
|
||||
id: string,
|
||||
): Promise<Readonly<ApprovalRequestRecord> | null> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT request_json AS "requestJson",
|
||||
request_digest AS "requestDigest"
|
||||
FROM "ql3"."approval_requests"
|
||||
WHERE request_id = $1
|
||||
LIMIT 2`,
|
||||
[id],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
return parseRequest(result.rows[0]!);
|
||||
}
|
||||
|
||||
async function dispatchById(
|
||||
queryable: Queryable,
|
||||
id: string,
|
||||
): Promise<Readonly<ApprovedActionDispatchRecord> | null> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT dispatch_json AS "dispatchJson",
|
||||
dispatch_digest AS "dispatchDigest"
|
||||
FROM "ql3"."approved_action_dispatches"
|
||||
WHERE dispatch_id = $1
|
||||
LIMIT 2`,
|
||||
[id],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
return parseDispatch(result.rows[0]!);
|
||||
}
|
||||
|
||||
async function auditById(
|
||||
queryable: Queryable,
|
||||
id: string,
|
||||
): Promise<Readonly<SecurityAuditRecord> | null> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT event_id AS "eventId", request_id AS "requestId",
|
||||
operation_id AS "operationId", project_id AS "projectId",
|
||||
subject_type AS "subjectType", subject_id AS "subjectId",
|
||||
authentication_id AS "authenticationId", outcome AS "outcome",
|
||||
reasons AS "reasonsJson",
|
||||
project_version AS "fenceProjectVersion",
|
||||
binding_version AS "fenceBindingVersion",
|
||||
occurred_at_ms AS "occurredAtMs"
|
||||
FROM "ql3"."security_audit_events"
|
||||
WHERE event_id = $1
|
||||
LIMIT 2`,
|
||||
[id],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
return parseAudit(result.rows[0]!);
|
||||
}
|
||||
|
||||
async function assertFence(
|
||||
client: PostgresClient,
|
||||
projectId: string,
|
||||
subject: Readonly<SecuritySubject>,
|
||||
fence: Readonly<SecurityPolicyFence>,
|
||||
): Promise<void> {
|
||||
const result = await client.query<Row>(
|
||||
`SELECT "ql3"."lock_approval_policy_fence"(
|
||||
$1::varchar, $2::varchar, $3::varchar, $4::integer, $5::integer
|
||||
) AS "matches"`,
|
||||
[
|
||||
projectId,
|
||||
subject.type,
|
||||
subject.id,
|
||||
fence.projectVersion,
|
||||
fence.bindingVersion,
|
||||
],
|
||||
);
|
||||
if (
|
||||
result.rows.length !== 1 ||
|
||||
!postgresRequiredBoolean(result.rows[0]!.matches, unavailable)
|
||||
) {
|
||||
throw new ApprovalPolicyFenceConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
async function insertAudit(
|
||||
client: PostgresClient,
|
||||
value: SecurityAuditRecord,
|
||||
): Promise<void> {
|
||||
const audit = normalizeSecurityAuditRecord(value);
|
||||
const result = await client.query(
|
||||
`INSERT INTO "ql3"."security_audit_events" (
|
||||
event_id, request_id, operation_id, project_id, subject_type,
|
||||
subject_id, authentication_id, outcome, reasons,
|
||||
project_version, binding_version, occurred_at_ms
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12
|
||||
)`,
|
||||
[
|
||||
audit.eventId,
|
||||
audit.requestId,
|
||||
audit.operationId,
|
||||
audit.projectId,
|
||||
audit.subject?.type ?? null,
|
||||
audit.subject?.id ?? null,
|
||||
audit.authenticationId,
|
||||
audit.outcome,
|
||||
JSON.stringify(audit.reasons),
|
||||
audit.fence?.projectVersion ?? null,
|
||||
audit.fence?.bindingVersion ?? null,
|
||||
audit.occurredAtMs,
|
||||
],
|
||||
);
|
||||
if (result.rowCount !== 1) throw unavailable();
|
||||
}
|
||||
|
||||
async function insertRequest(
|
||||
client: PostgresClient,
|
||||
request: Readonly<ApprovalRequestRecord>,
|
||||
): Promise<void> {
|
||||
const result = await client.query(
|
||||
`INSERT INTO "ql3"."approval_requests" (
|
||||
request_id, project_id, version, state, action_type, action_ref,
|
||||
action_digest, preview_digest, requested_by_type, requested_by_id,
|
||||
decision_id, consumption_id, dispatch_id, expires_at_ms, request_json,
|
||||
request_digest, updated_at_ms
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14,
|
||||
$15::jsonb, $16, $17
|
||||
)`,
|
||||
[
|
||||
request.id,
|
||||
request.projectId,
|
||||
request.version,
|
||||
request.state,
|
||||
request.action.actionType,
|
||||
request.action.actionRef,
|
||||
request.action.actionDigest,
|
||||
request.action.previewDigest,
|
||||
request.requestedBy.type,
|
||||
request.requestedBy.id,
|
||||
request.decisionId,
|
||||
request.consumptionId,
|
||||
request.dispatchId,
|
||||
request.expiresAtMs,
|
||||
JSON.stringify(request),
|
||||
approvalRequestDigest(request),
|
||||
request.consumedAtMs ?? request.decidedAtMs ?? request.requestedAtMs,
|
||||
],
|
||||
);
|
||||
if (result.rowCount !== 1) throw unavailable();
|
||||
}
|
||||
|
||||
async function updateRequest(
|
||||
client: PostgresClient,
|
||||
request: Readonly<ApprovalRequestRecord>,
|
||||
expectedVersion: number,
|
||||
): Promise<void> {
|
||||
const result = await client.query(
|
||||
`UPDATE "ql3"."approval_requests"
|
||||
SET version = $1, state = $2, decision_id = $3, consumption_id = $4,
|
||||
dispatch_id = $5, request_json = $6::jsonb, request_digest = $7,
|
||||
updated_at_ms = $8
|
||||
WHERE request_id = $9 AND version = $10`,
|
||||
[
|
||||
request.version,
|
||||
request.state,
|
||||
request.decisionId,
|
||||
request.consumptionId,
|
||||
request.dispatchId,
|
||||
JSON.stringify(request),
|
||||
approvalRequestDigest(request),
|
||||
request.consumedAtMs ?? request.decidedAtMs ?? request.requestedAtMs,
|
||||
request.id,
|
||||
expectedVersion,
|
||||
],
|
||||
);
|
||||
if (result.rowCount !== 1) throw new ApprovalRequestStateConflictError();
|
||||
}
|
||||
|
||||
async function insertDispatch(
|
||||
client: PostgresClient,
|
||||
dispatch: Readonly<ApprovedActionDispatchRecord>,
|
||||
): Promise<void> {
|
||||
const result = await client.query(
|
||||
`INSERT INTO "ql3"."approved_action_dispatches" (
|
||||
dispatch_id, approval_request_id, project_id, action_type, action_ref,
|
||||
action_digest, preview_digest, dispatch_json, dispatch_digest,
|
||||
created_at_ms
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10
|
||||
)`,
|
||||
[
|
||||
dispatch.id,
|
||||
dispatch.approvalRequestId,
|
||||
dispatch.projectId,
|
||||
dispatch.action.actionType,
|
||||
dispatch.action.actionRef,
|
||||
dispatch.action.actionDigest,
|
||||
dispatch.action.previewDigest,
|
||||
JSON.stringify(dispatch),
|
||||
approvedActionDispatchDigest(dispatch),
|
||||
dispatch.createdAtMs,
|
||||
],
|
||||
);
|
||||
if (result.rowCount !== 1) throw unavailable();
|
||||
}
|
||||
|
||||
/** Administration-only PostgreSQL Approval/Approved Action authority. */
|
||||
export class PostgresApprovalRequestRepository
|
||||
implements ApprovalRequestRepository
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw new TypeError('PostgreSQL Approval pool is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async #transaction<T>(
|
||||
work: (client: PostgresClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS;
|
||||
attempt += 1
|
||||
) {
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
} catch (error) {
|
||||
throw mappedError(error);
|
||||
}
|
||||
let began = false;
|
||||
try {
|
||||
await configurePostgresDefinitionTransaction(client);
|
||||
began = true;
|
||||
const result = await work(client);
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (began) await rollbackPostgresDefinitionTransaction(client);
|
||||
const state = postgresSqlState(error);
|
||||
if (
|
||||
state &&
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES.has(state) &&
|
||||
attempt + 1 < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw mappedError(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
async findById(
|
||||
id: string,
|
||||
): Promise<Readonly<ApprovalRequestRecord> | null> {
|
||||
if (typeof id !== 'string' || !IDENTIFIER_PATTERN.test(id)) {
|
||||
throw new TypeError('Approval request lookup identity is invalid');
|
||||
}
|
||||
try {
|
||||
return await requestById(this.pool, id);
|
||||
} catch (error) {
|
||||
throw mappedError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findDispatchById(
|
||||
id: string,
|
||||
): Promise<Readonly<ApprovedActionDispatchRecord> | null> {
|
||||
if (typeof id !== 'string' || !IDENTIFIER_PATTERN.test(id)) {
|
||||
throw new TypeError('Approved action dispatch identity is invalid');
|
||||
}
|
||||
try {
|
||||
return await dispatchById(this.pool, id);
|
||||
} catch (error) {
|
||||
throw mappedError(error);
|
||||
}
|
||||
}
|
||||
|
||||
create(
|
||||
command: CreateApprovalRequestCommand,
|
||||
): Promise<CreateApprovalRequestResult> {
|
||||
const request = normalizeApprovalRequestRecord(command.request);
|
||||
const audit = normalizeSecurityAuditRecord(command.audit);
|
||||
if (
|
||||
request.state !== 'pending' ||
|
||||
request.version !== 1 ||
|
||||
!auditMatches(audit, {
|
||||
operationId: 'approval.request',
|
||||
projectId: request.projectId,
|
||||
subject: request.requestedBy,
|
||||
outcome: 'approval_required',
|
||||
fence: request.requestFence,
|
||||
})
|
||||
) {
|
||||
throw new ApprovalMutationConflictError();
|
||||
}
|
||||
return this.#transaction(async (client) => {
|
||||
const existing = await requestById(client, request.id);
|
||||
if (existing) {
|
||||
const storedAudit = await auditById(client, audit.eventId);
|
||||
if (
|
||||
!same(existing, request) ||
|
||||
!storedAudit ||
|
||||
!same(storedAudit, audit)
|
||||
) {
|
||||
throw new ApprovalMutationConflictError();
|
||||
}
|
||||
return Object.freeze({ status: 'existing' as const, request });
|
||||
}
|
||||
await assertFence(
|
||||
client,
|
||||
request.projectId,
|
||||
request.requestedBy,
|
||||
request.requestFence,
|
||||
);
|
||||
await insertRequest(client, request);
|
||||
await insertAudit(client, audit);
|
||||
return Object.freeze({ status: 'created' as const, request });
|
||||
});
|
||||
}
|
||||
|
||||
decide(
|
||||
command: DecideDurableApprovalRequestCommand,
|
||||
): Promise<DecideApprovalRequestResult> {
|
||||
const audit = normalizeSecurityAuditRecord(command.audit);
|
||||
return this.#transaction(async (client) => {
|
||||
const current = await requestById(client, command.requestId);
|
||||
if (!current) throw new ApprovalRequestNotFoundError();
|
||||
const request = decideApprovalRequest(current, {
|
||||
expectedVersion: command.expectedVersion,
|
||||
decisionId: command.decisionId,
|
||||
decision: command.decision,
|
||||
reasonCode: command.reasonCode,
|
||||
principal: command.principal,
|
||||
decidedAtMs: command.decidedAtMs,
|
||||
authorizationFence: command.authorizationFence,
|
||||
});
|
||||
if (
|
||||
!auditMatches(audit, {
|
||||
operationId: 'approval.decide',
|
||||
projectId: request.projectId,
|
||||
subject: command.principal.subject,
|
||||
authenticationId: command.principal.authenticationId,
|
||||
outcome: 'allowed',
|
||||
fence: command.authorizationFence,
|
||||
})
|
||||
) {
|
||||
throw new ApprovalMutationConflictError();
|
||||
}
|
||||
if (current.version === 2 && current.decisionId === command.decisionId) {
|
||||
const storedAudit = await auditById(client, audit.eventId);
|
||||
if (!storedAudit || !same(storedAudit, audit)) {
|
||||
throw new ApprovalMutationConflictError();
|
||||
}
|
||||
return Object.freeze({ status: 'existing' as const, request });
|
||||
}
|
||||
await assertFence(
|
||||
client,
|
||||
request.projectId,
|
||||
command.principal.subject,
|
||||
command.authorizationFence,
|
||||
);
|
||||
await updateRequest(client, request, command.expectedVersion);
|
||||
await insertAudit(client, audit);
|
||||
return Object.freeze({ status: 'decided' as const, request });
|
||||
});
|
||||
}
|
||||
|
||||
consume(
|
||||
command: ConsumeDurableApprovalRequestCommand,
|
||||
): Promise<ConsumeDurableApprovalRequestResult> {
|
||||
const audit = normalizeSecurityAuditRecord(command.audit);
|
||||
return this.#transaction(async (client) => {
|
||||
const current = await requestById(client, command.requestId);
|
||||
if (!current) throw new ApprovalRequestNotFoundError();
|
||||
const result = consumeApprovalRequest(current, {
|
||||
expectedVersion: command.expectedVersion,
|
||||
consumptionId: command.consumptionId,
|
||||
dispatchId: command.dispatchId,
|
||||
action: command.action,
|
||||
requestedBy: command.requestedBy,
|
||||
consumedBy: command.consumedBy,
|
||||
consumedAtMs: command.consumedAtMs,
|
||||
authorizationFence: command.authorizationFence,
|
||||
});
|
||||
if (
|
||||
!auditMatches(audit, {
|
||||
operationId: 'approval.consume',
|
||||
projectId: result.request.projectId,
|
||||
subject: command.consumedBy,
|
||||
outcome: 'allowed',
|
||||
fence: command.authorizationFence,
|
||||
})
|
||||
) {
|
||||
throw new ApprovalMutationConflictError();
|
||||
}
|
||||
if (
|
||||
current.version === 3 &&
|
||||
current.consumptionId === command.consumptionId
|
||||
) {
|
||||
const storedAudit = await auditById(client, audit.eventId);
|
||||
const dispatch = await dispatchById(client, result.dispatch.id);
|
||||
const execution = await findPostgresApprovedActionExecution(
|
||||
client,
|
||||
result.dispatch.id,
|
||||
);
|
||||
if (
|
||||
!storedAudit ||
|
||||
!same(storedAudit, audit) ||
|
||||
!dispatch ||
|
||||
!same(dispatch, result.dispatch) ||
|
||||
!execution ||
|
||||
!same(execution, createApprovedActionExecution(result.dispatch))
|
||||
) {
|
||||
throw new ApprovalMutationConflictError();
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
request: result.request,
|
||||
dispatch: result.dispatch,
|
||||
});
|
||||
}
|
||||
await assertFence(
|
||||
client,
|
||||
result.request.projectId,
|
||||
command.requestedBy,
|
||||
command.authorizationFence,
|
||||
);
|
||||
await insertDispatch(client, result.dispatch);
|
||||
await insertPostgresApprovedActionExecutionBaseline(
|
||||
client,
|
||||
result.dispatch,
|
||||
);
|
||||
await updateRequest(client, result.request, command.expectedVersion);
|
||||
await insertAudit(client, audit);
|
||||
return Object.freeze({
|
||||
status: 'consumed' as const,
|
||||
request: result.request,
|
||||
dispatch: result.dispatch,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
import {
|
||||
ApprovalUnavailableError,
|
||||
approvalRequestDigest,
|
||||
normalizeApprovalRequestRecord,
|
||||
type ApprovalRequestRecord,
|
||||
} from '@qinglong/runtime-core/approved-action';
|
||||
import {
|
||||
approvalRequestUpdatedAtMs,
|
||||
assertApprovalDiscoveryProjectId,
|
||||
assertApprovalDiscoveryRequestId,
|
||||
assertApprovalRequestPageSize,
|
||||
normalizeApprovalRequestCursor,
|
||||
type ApprovalRequestDetail,
|
||||
type ApprovalRequestDetailSource,
|
||||
type ApprovalRequestPage,
|
||||
type ApprovalRequestSource,
|
||||
} from '@qinglong/runtime-core/approval-discovery';
|
||||
import {
|
||||
normalizeToolInvocationPreviewArtifact,
|
||||
type ToolInvocationPreviewArtifact,
|
||||
} from '@qinglong/runtime-core/tool-invocation-artifact';
|
||||
|
||||
import {
|
||||
postgresRequiredInteger,
|
||||
postgresRequiredJsonObject,
|
||||
postgresRequiredString,
|
||||
} from '../repository/definitionRepositorySupport';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
function unavailable(): ApprovalUnavailableError {
|
||||
return new ApprovalUnavailableError();
|
||||
}
|
||||
|
||||
function request(row: Row): Readonly<ApprovalRequestRecord> {
|
||||
try {
|
||||
const value = normalizeApprovalRequestRecord(
|
||||
postgresRequiredJsonObject(
|
||||
row.requestJson,
|
||||
unavailable,
|
||||
) as unknown as ApprovalRequestRecord,
|
||||
);
|
||||
if (
|
||||
approvalRequestDigest(value) !==
|
||||
postgresRequiredString(row.requestDigest, unavailable) ||
|
||||
approvalRequestUpdatedAtMs(value) !==
|
||||
postgresRequiredInteger(row.updatedAtMs, unavailable)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
if (error instanceof ApprovalUnavailableError) throw error;
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
function previewArtifact(
|
||||
row: Row,
|
||||
): Readonly<ToolInvocationPreviewArtifact> | null {
|
||||
if (row.previewArtifactId === null) return null;
|
||||
try {
|
||||
const value = normalizeToolInvocationPreviewArtifact(
|
||||
postgresRequiredJsonObject(
|
||||
row.previewArtifactJson,
|
||||
unavailable,
|
||||
) as unknown as ToolInvocationPreviewArtifact,
|
||||
);
|
||||
if (
|
||||
value.artifactId !== postgresRequiredString(row.previewArtifactId, unavailable) ||
|
||||
value.projectId !== postgresRequiredString(row.previewProjectId, unavailable) ||
|
||||
value.actionRef !== postgresRequiredString(row.previewActionRef, unavailable) ||
|
||||
value.actionDigest !== postgresRequiredString(row.previewActionDigest, unavailable) ||
|
||||
value.previewDigest !== postgresRequiredString(row.storedPreviewDigest, unavailable) ||
|
||||
value.redactionContractDigest !==
|
||||
postgresRequiredString(row.redactionContractDigest, unavailable) ||
|
||||
value.artifactDigest !== postgresRequiredString(row.previewArtifactDigest, unavailable) ||
|
||||
value.byteLength !== postgresRequiredInteger(row.previewByteLength, unavailable) ||
|
||||
value.sealedAtMs !== postgresRequiredInteger(row.previewSealedAtMs, unavailable)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
if (error instanceof ApprovalUnavailableError) throw error;
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
/** Read-only Approval history using the existing Project keyset index. */
|
||||
export class PostgresApprovalRequestSource
|
||||
implements ApprovalRequestSource, ApprovalRequestDetailSource
|
||||
{
|
||||
constructor(private readonly pool: Pick<PostgresPool, 'query'>) {
|
||||
if (!pool || typeof pool.query !== 'function') {
|
||||
throw new TypeError('PostgreSQL Approval discovery pool is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async listApprovalRequests(options: {
|
||||
readonly projectId: string;
|
||||
readonly limit: number;
|
||||
readonly after?: { readonly updatedAtMs: number; readonly requestId: string };
|
||||
}): Promise<Readonly<ApprovalRequestPage>> {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new TypeError('Approval discovery options are invalid');
|
||||
}
|
||||
const keys = Object.keys(options);
|
||||
if (
|
||||
!keys.includes('projectId') ||
|
||||
!keys.includes('limit') ||
|
||||
keys.some((key) => !['projectId', 'limit', 'after'].includes(key))
|
||||
) {
|
||||
throw new TypeError('Approval discovery options shape is invalid');
|
||||
}
|
||||
assertApprovalDiscoveryProjectId(options.projectId);
|
||||
assertApprovalRequestPageSize(options.limit);
|
||||
const after = options.after
|
||||
? normalizeApprovalRequestCursor(options.after)
|
||||
: undefined;
|
||||
try {
|
||||
const result = await this.pool.query<Row>(
|
||||
`SELECT request_json AS "requestJson",
|
||||
request_digest AS "requestDigest",
|
||||
updated_at_ms AS "updatedAtMs"
|
||||
FROM "ql3"."approval_requests"
|
||||
WHERE project_id = $1
|
||||
AND (
|
||||
$2::bigint IS NULL OR updated_at_ms < $2 OR
|
||||
(updated_at_ms = $2 AND request_id < $3)
|
||||
)
|
||||
ORDER BY updated_at_ms DESC, request_id DESC
|
||||
LIMIT $4`,
|
||||
[
|
||||
options.projectId,
|
||||
after?.updatedAtMs ?? null,
|
||||
after?.requestId ?? '',
|
||||
options.limit + 1,
|
||||
],
|
||||
);
|
||||
const truncated = result.rows.length > options.limit;
|
||||
const requests = Object.freeze(
|
||||
result.rows.slice(0, options.limit).map(request),
|
||||
);
|
||||
const last = requests.at(-1);
|
||||
return Object.freeze({
|
||||
requests,
|
||||
truncated,
|
||||
...(truncated && last
|
||||
? {
|
||||
next: Object.freeze({
|
||||
updatedAtMs: approvalRequestUpdatedAtMs(last),
|
||||
requestId: last.id,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
async getApprovalRequestDetail(options: {
|
||||
readonly projectId: string;
|
||||
readonly requestId: string;
|
||||
}): Promise<Readonly<ApprovalRequestDetail> | null> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).length !== 2 ||
|
||||
!Object.hasOwn(options, 'projectId') ||
|
||||
!Object.hasOwn(options, 'requestId')
|
||||
) {
|
||||
throw new TypeError('Approval detail options are invalid');
|
||||
}
|
||||
assertApprovalDiscoveryProjectId(options.projectId);
|
||||
assertApprovalDiscoveryRequestId(options.requestId);
|
||||
try {
|
||||
const result = await this.pool.query<Row>(
|
||||
`SELECT a.request_json AS "requestJson",
|
||||
a.request_digest AS "requestDigest",
|
||||
a.updated_at_ms AS "updatedAtMs",
|
||||
p.artifact_id AS "previewArtifactId",
|
||||
p.project_id AS "previewProjectId",
|
||||
p.action_ref AS "previewActionRef",
|
||||
p.action_digest AS "previewActionDigest",
|
||||
p.preview_digest AS "storedPreviewDigest",
|
||||
p.redaction_contract_digest AS "redactionContractDigest",
|
||||
p.artifact_digest AS "previewArtifactDigest",
|
||||
p.byte_length AS "previewByteLength",
|
||||
p.sealed_at_ms AS "previewSealedAtMs",
|
||||
p.artifact_json AS "previewArtifactJson"
|
||||
FROM "ql3"."approval_requests" a
|
||||
LEFT JOIN "ql3"."tool_invocation_preview_artifacts" p
|
||||
ON a.action_type = 'tool.invoke'
|
||||
AND p.project_id = a.project_id
|
||||
AND p.action_ref = a.action_ref
|
||||
AND p.action_digest = a.action_digest
|
||||
AND p.preview_digest = a.preview_digest
|
||||
WHERE a.project_id = $1 AND a.request_id = $2
|
||||
LIMIT 2`,
|
||||
[options.projectId, options.requestId],
|
||||
);
|
||||
if (result.rows.length > 1) throw unavailable();
|
||||
if (!result.rows[0]) return null;
|
||||
const approval = request(result.rows[0]);
|
||||
const preview = previewArtifact(result.rows[0]);
|
||||
if (
|
||||
preview &&
|
||||
(approval.action.actionType !== 'tool.invoke' ||
|
||||
approval.projectId !== preview.projectId ||
|
||||
approval.action.actionRef !== preview.actionRef ||
|
||||
approval.action.actionDigest !== preview.actionDigest ||
|
||||
approval.action.previewDigest !== preview.previewDigest)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return Object.freeze({
|
||||
request: approval,
|
||||
preview: preview?.preview ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ApprovalUnavailableError) throw error;
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
}
|
||||
+512
@@ -0,0 +1,512 @@
|
||||
import type {
|
||||
PostgresClient,
|
||||
PostgresPool,
|
||||
} from '@qinglong/runtime-core';
|
||||
import {
|
||||
normalizeApprovedActionDispatchRecord,
|
||||
type ApprovedActionDispatchRecord,
|
||||
} from '@qinglong/runtime-core/approved-action';
|
||||
import {
|
||||
ApprovedActionExecutionFenceConflictError,
|
||||
ApprovedActionExecutionStateConflictError,
|
||||
ApprovedActionExecutionUnavailableError,
|
||||
approvedActionExecutionEffectiveStatus,
|
||||
claimApprovedActionExecution,
|
||||
completeApprovedActionExecution,
|
||||
createApprovedActionExecution,
|
||||
normalizeApprovedActionExecutionCursor,
|
||||
normalizeApprovedActionExecutionRecord,
|
||||
normalizeApprovedActionExecutionSnapshot,
|
||||
releaseApprovedActionExecutionBeforeStart,
|
||||
renewApprovedActionExecution,
|
||||
startApprovedActionExecution,
|
||||
type ApprovedActionExecutionRecord,
|
||||
type ApprovedActionExecutionRepository,
|
||||
type ApprovedActionExecutionSnapshot,
|
||||
type ClaimApprovedActionExecutionCommand,
|
||||
type ClaimApprovedActionExecutionResult,
|
||||
type CompleteApprovedActionExecutionCommand,
|
||||
type ListDueApprovedActionExecutionsQuery,
|
||||
type ListDueApprovedActionExecutionsResult,
|
||||
type ReleaseApprovedActionExecutionBeforeStartCommand,
|
||||
type RenewApprovedActionExecutionCommand,
|
||||
type StartApprovedActionExecutionCommand,
|
||||
} from '@qinglong/runtime-core/approved-action-execution';
|
||||
|
||||
import {
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES,
|
||||
POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS,
|
||||
configurePostgresDefinitionTransaction,
|
||||
postgresRequiredJsonObject,
|
||||
postgresRequiredString,
|
||||
postgresSqlState,
|
||||
rollbackPostgresDefinitionTransaction,
|
||||
} from '../repository/definitionRepositorySupport';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
type Queryable = Pick<PostgresPool, 'query'> | Pick<PostgresClient, 'query'>;
|
||||
|
||||
function unavailable(): ApprovedActionExecutionUnavailableError {
|
||||
return new ApprovedActionExecutionUnavailableError();
|
||||
}
|
||||
|
||||
function parseExecution(row: Row): Readonly<ApprovedActionExecutionRecord> {
|
||||
try {
|
||||
const execution = normalizeApprovedActionExecutionRecord(
|
||||
postgresRequiredJsonObject(
|
||||
row.executionJson,
|
||||
unavailable,
|
||||
) as unknown as ApprovedActionExecutionRecord,
|
||||
);
|
||||
if (
|
||||
execution.executionDigest !==
|
||||
postgresRequiredString(row.executionDigest, unavailable)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return execution;
|
||||
} catch (error) {
|
||||
if (error instanceof ApprovedActionExecutionUnavailableError) throw error;
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
function parseDispatch(row: Row): Readonly<ApprovedActionDispatchRecord> {
|
||||
try {
|
||||
return normalizeApprovedActionDispatchRecord(
|
||||
postgresRequiredJsonObject(
|
||||
row.dispatchJson,
|
||||
unavailable,
|
||||
) as unknown as ApprovedActionDispatchRecord,
|
||||
);
|
||||
} catch {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
function mappedError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof ApprovedActionExecutionFenceConflictError ||
|
||||
error instanceof ApprovedActionExecutionStateConflictError ||
|
||||
error instanceof ApprovedActionExecutionUnavailableError ||
|
||||
(error instanceof Error &&
|
||||
error.name.startsWith('ApprovedActionExecution'))
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
const state = postgresSqlState(error);
|
||||
if (state === '23503' || state === '23505' || state === '23514') {
|
||||
return new ApprovedActionExecutionStateConflictError();
|
||||
}
|
||||
return new ApprovedActionExecutionUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function executionValues(
|
||||
execution: Readonly<ApprovedActionExecutionRecord>,
|
||||
): readonly (string | number | null)[] {
|
||||
return [
|
||||
execution.dispatchId,
|
||||
execution.dispatchDigest,
|
||||
execution.projectId,
|
||||
execution.status,
|
||||
execution.version,
|
||||
execution.attemptCount,
|
||||
execution.maxAttempts,
|
||||
execution.eligibleAtMs,
|
||||
execution.nextAttemptAtMs,
|
||||
execution.leaseOwner,
|
||||
execution.leaseToken,
|
||||
execution.leaseExpiresAtMs,
|
||||
execution.startedAtMs,
|
||||
execution.resultMutationId,
|
||||
execution.resultCode,
|
||||
execution.resultDigest,
|
||||
execution.completedAtMs,
|
||||
execution.createdAtMs,
|
||||
execution.updatedAtMs,
|
||||
JSON.stringify(execution),
|
||||
execution.executionDigest,
|
||||
];
|
||||
}
|
||||
|
||||
export async function insertPostgresApprovedActionExecutionBaseline(
|
||||
client: PostgresClient,
|
||||
dispatchValue: ApprovedActionDispatchRecord,
|
||||
): Promise<Readonly<ApprovedActionExecutionRecord>> {
|
||||
const execution = createApprovedActionExecution(dispatchValue);
|
||||
const result = await client.query(
|
||||
`INSERT INTO "ql3"."approved_action_executions" (
|
||||
dispatch_id, dispatch_digest, project_id, status, version,
|
||||
attempt_count, max_attempts, eligible_at_ms, next_attempt_at_ms,
|
||||
lease_owner, lease_token, lease_expires_at_ms, started_at_ms,
|
||||
result_mutation_id, result_code, result_digest, completed_at_ms,
|
||||
created_at_ms, updated_at_ms, execution_json, execution_digest
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14,
|
||||
$15, $16, $17, $18, $19, $20::jsonb, $21
|
||||
)`,
|
||||
executionValues(execution),
|
||||
);
|
||||
if (result.rowCount !== 1) throw unavailable();
|
||||
return execution;
|
||||
}
|
||||
|
||||
export async function findPostgresApprovedActionExecution(
|
||||
queryable: Queryable,
|
||||
dispatchId: string,
|
||||
): Promise<Readonly<ApprovedActionExecutionRecord> | null> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT execution_json AS "executionJson",
|
||||
execution_digest AS "executionDigest"
|
||||
FROM "ql3"."approved_action_executions"
|
||||
WHERE dispatch_id = $1
|
||||
LIMIT 2`,
|
||||
[dispatchId],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
return parseExecution(result.rows[0]!);
|
||||
}
|
||||
|
||||
async function loadSnapshot(
|
||||
queryable: Queryable,
|
||||
dispatchId: string,
|
||||
lock = false,
|
||||
): Promise<Readonly<ApprovedActionExecutionSnapshot> | null> {
|
||||
const result = await queryable.query<Row>(
|
||||
`SELECT execution.execution_json AS "executionJson",
|
||||
execution.execution_digest AS "executionDigest",
|
||||
dispatch.dispatch_json AS "dispatchJson"
|
||||
FROM "ql3"."approved_action_executions" AS execution
|
||||
JOIN "ql3"."approved_action_dispatches" AS dispatch
|
||||
ON dispatch.dispatch_id = execution.dispatch_id
|
||||
WHERE execution.dispatch_id = $1
|
||||
LIMIT 2
|
||||
${lock ? 'FOR UPDATE OF execution' : ''}`,
|
||||
[dispatchId],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
return normalizeApprovedActionExecutionSnapshot({
|
||||
dispatch: parseDispatch(result.rows[0]!),
|
||||
execution: parseExecution(result.rows[0]!),
|
||||
});
|
||||
}
|
||||
|
||||
async function replaceExecution(
|
||||
client: PostgresClient,
|
||||
previous: Readonly<ApprovedActionExecutionRecord>,
|
||||
next: Readonly<ApprovedActionExecutionRecord>,
|
||||
): Promise<void> {
|
||||
const values = executionValues(next);
|
||||
const result = await client.query(
|
||||
`UPDATE "ql3"."approved_action_executions"
|
||||
SET dispatch_digest = $1, project_id = $2, status = $3,
|
||||
version = $4, attempt_count = $5, max_attempts = $6,
|
||||
eligible_at_ms = $7, next_attempt_at_ms = $8, lease_owner = $9,
|
||||
lease_token = $10, lease_expires_at_ms = $11, started_at_ms = $12,
|
||||
result_mutation_id = $13, result_code = $14, result_digest = $15,
|
||||
completed_at_ms = $16, created_at_ms = $17, updated_at_ms = $18,
|
||||
execution_json = $19::jsonb, execution_digest = $20
|
||||
WHERE dispatch_id = $21 AND version = $22
|
||||
AND execution_digest = $23`,
|
||||
[
|
||||
...values.slice(1),
|
||||
previous.dispatchId,
|
||||
previous.version,
|
||||
previous.executionDigest,
|
||||
],
|
||||
);
|
||||
if (result.rowCount !== 1) {
|
||||
throw new ApprovedActionExecutionFenceConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
function assertPageSize(value: number): void {
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > 64) {
|
||||
throw new ApprovedActionExecutionStateConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
function actionTypes(values: readonly string[]): readonly string[] {
|
||||
if (
|
||||
!Array.isArray(values) ||
|
||||
values.length > 64 ||
|
||||
values.some(
|
||||
(value) =>
|
||||
typeof value !== 'string' ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value),
|
||||
) ||
|
||||
new Set(values).size !== values.length
|
||||
) {
|
||||
throw new ApprovedActionExecutionStateConflictError();
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export class PostgresApprovedActionExecutionRepository
|
||||
implements ApprovedActionExecutionRepository
|
||||
{
|
||||
constructor(private readonly pool: PostgresPool) {
|
||||
if (
|
||||
!pool ||
|
||||
typeof pool.query !== 'function' ||
|
||||
typeof pool.connect !== 'function'
|
||||
) {
|
||||
throw new TypeError('PostgreSQL Approved Action execution pool is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async #transaction<T>(
|
||||
work: (client: PostgresClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS;
|
||||
attempt += 1
|
||||
) {
|
||||
let client: PostgresClient;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
} catch (error) {
|
||||
throw mappedError(error);
|
||||
}
|
||||
let began = false;
|
||||
try {
|
||||
await configurePostgresDefinitionTransaction(client);
|
||||
began = true;
|
||||
const result = await work(client);
|
||||
await client.query('COMMIT');
|
||||
began = false;
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (began) await rollbackPostgresDefinitionTransaction(client);
|
||||
const state = postgresSqlState(error);
|
||||
if (
|
||||
state &&
|
||||
POSTGRES_DEFINITION_RETRYABLE_SQL_STATES.has(state) &&
|
||||
attempt + 1 < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw mappedError(error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
|
||||
async findExecutionByDispatchId(
|
||||
dispatchId: string,
|
||||
): Promise<Readonly<ApprovedActionExecutionSnapshot> | null> {
|
||||
try {
|
||||
return await loadSnapshot(this.pool, dispatchId);
|
||||
} catch (error) {
|
||||
throw mappedError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async listDueExecutions(
|
||||
query: ListDueApprovedActionExecutionsQuery,
|
||||
): Promise<ListDueApprovedActionExecutionsResult> {
|
||||
assertPageSize(query.limit);
|
||||
const handledActionTypes = actionTypes(query.actionTypes);
|
||||
if (!Number.isSafeInteger(query.nowMs) || query.nowMs < 0) {
|
||||
throw new ApprovedActionExecutionStateConflictError();
|
||||
}
|
||||
if (handledActionTypes.length === 0) {
|
||||
return Object.freeze({ executions: Object.freeze([]), truncated: false });
|
||||
}
|
||||
const cursor = query.cursor
|
||||
? normalizeApprovedActionExecutionCursor(query.cursor)
|
||||
: undefined;
|
||||
try {
|
||||
const result = await this.pool.query<Row>(
|
||||
`SELECT execution.execution_json AS "executionJson",
|
||||
execution.execution_digest AS "executionDigest",
|
||||
dispatch.dispatch_json AS "dispatchJson"
|
||||
FROM "ql3"."approved_action_executions" AS execution
|
||||
JOIN "ql3"."approved_action_dispatches" AS dispatch
|
||||
ON dispatch.dispatch_id = execution.dispatch_id
|
||||
WHERE execution.status IN ('pending','leased','retry_wait')
|
||||
AND execution.eligible_at_ms <= $1
|
||||
AND dispatch.action_type = ANY($2::varchar[])
|
||||
AND (
|
||||
$3::varchar IS NULL OR execution.eligible_at_ms > $4 OR
|
||||
(
|
||||
execution.eligible_at_ms = $4
|
||||
AND execution.dispatch_id > $3
|
||||
)
|
||||
)
|
||||
ORDER BY execution.eligible_at_ms, execution.dispatch_id
|
||||
LIMIT $5`,
|
||||
[
|
||||
query.nowMs,
|
||||
handledActionTypes,
|
||||
cursor?.dispatchId ?? null,
|
||||
cursor?.eligibleAtMs ?? 0,
|
||||
query.limit + 1,
|
||||
],
|
||||
);
|
||||
const truncated = result.rows.length > query.limit;
|
||||
const rows = truncated
|
||||
? result.rows.slice(0, query.limit)
|
||||
: result.rows;
|
||||
const executions = rows.map((row) =>
|
||||
normalizeApprovedActionExecutionSnapshot({
|
||||
dispatch: parseDispatch(row),
|
||||
execution: parseExecution(row),
|
||||
}),
|
||||
);
|
||||
const last = executions.at(-1)?.execution;
|
||||
return Object.freeze({
|
||||
executions: Object.freeze(executions),
|
||||
truncated,
|
||||
...(truncated && last?.eligibleAtMs !== null
|
||||
? {
|
||||
nextCursor: Object.freeze({
|
||||
eligibleAtMs: last!.eligibleAtMs!,
|
||||
dispatchId: last!.dispatchId,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
} catch (error) {
|
||||
throw mappedError(error);
|
||||
}
|
||||
}
|
||||
|
||||
claimExecution(
|
||||
command: ClaimApprovedActionExecutionCommand,
|
||||
): Promise<ClaimApprovedActionExecutionResult> {
|
||||
return this.#transaction(async (client) => {
|
||||
const current = await loadSnapshot(client, command.dispatchId, true);
|
||||
if (!current) return Object.freeze({ status: 'not_found' as const });
|
||||
const effective = approvedActionExecutionEffectiveStatus(
|
||||
current.execution,
|
||||
command.nowMs,
|
||||
);
|
||||
if (
|
||||
current.execution.status === 'leased' &&
|
||||
current.execution.leaseExpiresAtMs !== null &&
|
||||
current.execution.leaseExpiresAtMs <= command.nowMs &&
|
||||
current.execution.attemptCount >= current.execution.maxAttempts
|
||||
) {
|
||||
return Object.freeze({
|
||||
status: 'recovery_required' as const,
|
||||
snapshot: current,
|
||||
});
|
||||
}
|
||||
const due =
|
||||
current.execution.eligibleAtMs !== null &&
|
||||
current.execution.eligibleAtMs <= command.nowMs;
|
||||
if (
|
||||
effective !== 'pending' &&
|
||||
effective !== 'retry_wait' &&
|
||||
!(effective === 'leased' && due)
|
||||
) {
|
||||
return Object.freeze({ status: effective, snapshot: current });
|
||||
}
|
||||
if (!due) {
|
||||
return Object.freeze({
|
||||
status: 'not_due' as const,
|
||||
snapshot: current,
|
||||
});
|
||||
}
|
||||
const next = claimApprovedActionExecution(current.execution, {
|
||||
owner: command.owner,
|
||||
leaseToken: command.leaseToken,
|
||||
nowMs: command.nowMs,
|
||||
leaseDurationMs: command.leaseDurationMs,
|
||||
});
|
||||
await replaceExecution(client, current.execution, next);
|
||||
return Object.freeze({
|
||||
status: 'claimed' as const,
|
||||
snapshot: Object.freeze({
|
||||
dispatch: current.dispatch,
|
||||
execution: next,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
startExecution(
|
||||
command: StartApprovedActionExecutionCommand,
|
||||
): Promise<Readonly<ApprovedActionExecutionSnapshot>> {
|
||||
return this.#mutate(command.dispatchId, (current) =>
|
||||
startApprovedActionExecution(current, command),
|
||||
);
|
||||
}
|
||||
|
||||
renewExecution(
|
||||
command: RenewApprovedActionExecutionCommand,
|
||||
): Promise<Readonly<ApprovedActionExecutionSnapshot>> {
|
||||
return this.#mutate(command.dispatchId, (current) =>
|
||||
renewApprovedActionExecution(current.execution, {
|
||||
owner: command.owner,
|
||||
leaseToken: command.leaseToken,
|
||||
expectedVersion: command.expectedVersion,
|
||||
nowMs: command.nowMs,
|
||||
leaseDurationMs: command.leaseDurationMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
releaseExecutionBeforeStart(
|
||||
command: ReleaseApprovedActionExecutionBeforeStartCommand,
|
||||
): Promise<Readonly<ApprovedActionExecutionSnapshot>> {
|
||||
return this.#mutate(command.dispatchId, (current) =>
|
||||
releaseApprovedActionExecutionBeforeStart(current.execution, {
|
||||
owner: command.owner,
|
||||
leaseToken: command.leaseToken,
|
||||
expectedVersion: command.expectedVersion,
|
||||
resultMutationId: command.resultMutationId,
|
||||
resultCode: command.resultCode,
|
||||
atMs: command.atMs,
|
||||
...(command.retryAtMs === undefined
|
||||
? {}
|
||||
: { retryAtMs: command.retryAtMs }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
completeExecution(
|
||||
command: CompleteApprovedActionExecutionCommand,
|
||||
): Promise<Readonly<ApprovedActionExecutionSnapshot>> {
|
||||
return this.#mutate(command.dispatchId, (current) =>
|
||||
completeApprovedActionExecution(current.execution, {
|
||||
owner: command.owner,
|
||||
leaseToken: command.leaseToken,
|
||||
expectedVersion: command.expectedVersion,
|
||||
resultMutationId: command.resultMutationId,
|
||||
outcome: command.outcome,
|
||||
resultCode: command.resultCode,
|
||||
...(command.resultDigest === undefined
|
||||
? {}
|
||||
: { resultDigest: command.resultDigest }),
|
||||
completedAtMs: command.completedAtMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#mutate(
|
||||
dispatchId: string,
|
||||
transition: (
|
||||
current: Readonly<ApprovedActionExecutionSnapshot>,
|
||||
) => Readonly<ApprovedActionExecutionRecord>,
|
||||
): Promise<Readonly<ApprovedActionExecutionSnapshot>> {
|
||||
return this.#transaction(async (client) => {
|
||||
const current = await loadSnapshot(client, dispatchId, true);
|
||||
if (!current) throw new ApprovedActionExecutionStateConflictError();
|
||||
const next = transition(current);
|
||||
await replaceExecution(client, current.execution, next);
|
||||
return Object.freeze({
|
||||
dispatch: current.dispatch,
|
||||
execution: next,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user