mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 00:17:47 +08:00
1169 lines
43 KiB
TypeScript
1169 lines
43 KiB
TypeScript
import { QueryTypes, Sequelize, Transaction } from 'sequelize';
|
|
import { APPROVED_ACTION_DISPATCH_TABLE } from '../../../migrations/0020-approval-requests';
|
|
import { APPROVED_ACTION_DISPATCH_EXECUTION_TABLE } from '../../../migrations/0021-approved-action-dispatch-executions';
|
|
import {
|
|
APPROVED_ACTION_RECOVERY_CONTROL_TABLE,
|
|
APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE,
|
|
} from '../../../migrations/0022-approved-action-recovery';
|
|
import { APPROVED_ACTION_RECOVERY_AUTHORIZATION_TABLE } from '../../../migrations/0024-approved-action-recovery-authorization';
|
|
import {
|
|
PROJECT_ROLE_BINDING_TABLE,
|
|
PROJECT_TABLE,
|
|
} from '../../../migrations/0017-project-policy';
|
|
import {
|
|
ApprovedActionDispatchBindingConflictError,
|
|
assertApprovedActionLeaseIdentity,
|
|
assertApprovedActionResultCode,
|
|
normalizeApprovedActionDispatchExecutionRecord,
|
|
type ApprovedActionDispatchExecutionRecord,
|
|
type ApprovedActionDispatchExecutionSnapshot,
|
|
} from '../../domain/approvedActionDispatchExecution';
|
|
import {
|
|
ApprovedActionRecoveryBindingConflictError,
|
|
ApprovedActionRecoveryFenceRejectedError,
|
|
ApprovedActionRecoveryRepositoryError,
|
|
MAX_APPROVED_ACTION_RECOVERY_FINDINGS,
|
|
assertApprovedActionEvidenceDigest,
|
|
assertApprovedActionRecoveryLeaseDuration,
|
|
assertApprovedActionRecoveryPageSize,
|
|
normalizeApprovedActionRecoveryControlRecord,
|
|
normalizeApprovedActionRecoveryCursor,
|
|
normalizeApprovedActionRecoveryResolutionRecord,
|
|
type ApprovedActionRecoveryControlRecord,
|
|
type ApprovedActionRecoveryResolutionRecord,
|
|
type ApprovedActionRecoverySnapshot,
|
|
} from '../../domain/approvedActionRecovery';
|
|
import {
|
|
normalizeApprovedActionRecoveryAuthorizationFact,
|
|
type ApprovedActionRecoveryAuthorizationFact,
|
|
} from '../../domain/approvedActionRecoveryAuthorization';
|
|
import {
|
|
InvalidApprovalValueError,
|
|
assertApprovalMutationId,
|
|
assertApprovalTimestamp,
|
|
normalizeApprovedActionDispatchRecord,
|
|
type ApprovedActionDispatchRecord,
|
|
} from '../../domain/approvalRequest';
|
|
import { normalizePolicySubject } from '../../domain/projectPolicy';
|
|
import type {
|
|
ApprovedActionRecoveryRepository,
|
|
ClaimApprovedActionRecoveryCommand,
|
|
ClaimApprovedActionRecoveryResult,
|
|
ListDueApprovedActionRecoveriesQuery,
|
|
ListDueApprovedActionRecoveriesResult,
|
|
RecordApprovedActionRecoveryFindingCommand,
|
|
ResolveApprovedActionRecoveryCommand,
|
|
ResolveApprovedActionRecoveryResult,
|
|
} from '../../ports/approvedActionRecoveryRepository';
|
|
|
|
const RETRY_ATTEMPTS = 5;
|
|
|
|
interface RecoveryRow {
|
|
dispatch_id: string;
|
|
approval_request_id: string;
|
|
approval_request_version: number | string;
|
|
dispatch_project_id: string;
|
|
dispatch_state: string;
|
|
permission: string;
|
|
action_type: string;
|
|
action_ref: string;
|
|
action_digest: string;
|
|
preview_digest: string;
|
|
requested_by_type: string;
|
|
requested_by_id: string;
|
|
consumed_by_type: string;
|
|
consumed_by_id: string;
|
|
dispatch_created_at_ms: number | string;
|
|
execution_project_id: string;
|
|
execution_status: string;
|
|
execution_version: number | string;
|
|
attempt_count: number | string;
|
|
max_attempts: number | string;
|
|
eligible_at_ms: number | string | null;
|
|
next_attempt_at_ms: number | string | null;
|
|
execution_lease_owner: string | null;
|
|
execution_lease_token: string | null;
|
|
execution_lease_expires_at_ms: number | string | null;
|
|
started_at_ms: number | string | null;
|
|
execution_result_mutation_id: string | null;
|
|
execution_last_result_code: string | null;
|
|
completed_at_ms: number | string | null;
|
|
execution_created_at_ms: number | string;
|
|
execution_updated_at_ms: number | string;
|
|
recovery_project_id: string;
|
|
recovery_execution_version: number | string;
|
|
recovery_status: string;
|
|
recovery_version: number | string;
|
|
next_scan_at_ms: number | string | null;
|
|
recovery_lease_owner: string | null;
|
|
recovery_lease_token: string | null;
|
|
recovery_lease_expires_at_ms: number | string | null;
|
|
finding_count: number | string;
|
|
last_finding_mutation_id: string | null;
|
|
last_finding: string | null;
|
|
recovery_last_result_code: string | null;
|
|
last_evidence_digest: string | null;
|
|
recovery_resolution_mutation_id: string | null;
|
|
recovery_created_at_ms: number | string;
|
|
recovery_updated_at_ms: number | string;
|
|
resolution_project_id: string | null;
|
|
resolution_execution_version: number | string | null;
|
|
resolution_mutation_id: string | null;
|
|
resolution_source: string | null;
|
|
resolution_decision: string | null;
|
|
resolution_evidence_digest: string | null;
|
|
resolution_reason_code: string | null;
|
|
resolved_by_type: string | null;
|
|
resolved_by_id: string | null;
|
|
resolved_at_ms: number | string | null;
|
|
}
|
|
|
|
interface AuthorizationFactRow {
|
|
dispatch_id: string;
|
|
project_id: string;
|
|
mutation_id: string;
|
|
resolved_by_id: string;
|
|
authentication_id: string;
|
|
assurance: string;
|
|
authenticated_at_ms: number | string;
|
|
project_version: number | string;
|
|
binding_version: number | string;
|
|
authorized_at_ms: number | string;
|
|
fact_digest: string;
|
|
}
|
|
|
|
interface PolicyFenceRow {
|
|
project_version: number | string;
|
|
binding_version: number | string | null;
|
|
}
|
|
|
|
const RECOVERY_SELECT = `SELECT dispatch.id AS dispatch_id,
|
|
dispatch.approval_request_id AS approval_request_id,
|
|
dispatch.approval_request_version AS approval_request_version,
|
|
dispatch.project_id AS dispatch_project_id,
|
|
dispatch.state AS dispatch_state,
|
|
dispatch.permission AS permission,
|
|
dispatch.action_type AS action_type,
|
|
dispatch.action_ref AS action_ref,
|
|
dispatch.action_digest AS action_digest,
|
|
dispatch.preview_digest AS preview_digest,
|
|
dispatch.requested_by_type AS requested_by_type,
|
|
dispatch.requested_by_id AS requested_by_id,
|
|
dispatch.consumed_by_type AS consumed_by_type,
|
|
dispatch.consumed_by_id AS consumed_by_id,
|
|
dispatch.created_at_ms AS dispatch_created_at_ms,
|
|
execution.project_id AS execution_project_id,
|
|
execution.status AS execution_status,
|
|
execution.version AS execution_version,
|
|
execution.attempt_count AS attempt_count,
|
|
execution.max_attempts AS max_attempts,
|
|
execution.eligible_at_ms AS eligible_at_ms,
|
|
execution.next_attempt_at_ms AS next_attempt_at_ms,
|
|
execution.lease_owner AS execution_lease_owner,
|
|
execution.lease_token AS execution_lease_token,
|
|
execution.lease_expires_at_ms AS execution_lease_expires_at_ms,
|
|
execution.started_at_ms AS started_at_ms,
|
|
execution.result_mutation_id AS execution_result_mutation_id,
|
|
execution.last_result_code AS execution_last_result_code,
|
|
execution.completed_at_ms AS completed_at_ms,
|
|
execution.created_at_ms AS execution_created_at_ms,
|
|
execution.updated_at_ms AS execution_updated_at_ms,
|
|
recovery.project_id AS recovery_project_id,
|
|
recovery.execution_version AS recovery_execution_version,
|
|
recovery.status AS recovery_status,
|
|
recovery.version AS recovery_version,
|
|
recovery.next_scan_at_ms AS next_scan_at_ms,
|
|
recovery.lease_owner AS recovery_lease_owner,
|
|
recovery.lease_token AS recovery_lease_token,
|
|
recovery.lease_expires_at_ms AS recovery_lease_expires_at_ms,
|
|
recovery.finding_count AS finding_count,
|
|
recovery.last_finding_mutation_id AS last_finding_mutation_id,
|
|
recovery.last_finding AS last_finding,
|
|
recovery.last_result_code AS recovery_last_result_code,
|
|
recovery.last_evidence_digest AS last_evidence_digest,
|
|
recovery.resolution_mutation_id AS recovery_resolution_mutation_id,
|
|
recovery.created_at_ms AS recovery_created_at_ms,
|
|
recovery.updated_at_ms AS recovery_updated_at_ms,
|
|
resolution.project_id AS resolution_project_id,
|
|
resolution.execution_version AS resolution_execution_version,
|
|
resolution.mutation_id AS resolution_mutation_id,
|
|
resolution.source AS resolution_source,
|
|
resolution.decision AS resolution_decision,
|
|
resolution.evidence_digest AS resolution_evidence_digest,
|
|
resolution.reason_code AS resolution_reason_code,
|
|
resolution.resolved_by_type AS resolved_by_type,
|
|
resolution.resolved_by_id AS resolved_by_id,
|
|
resolution.resolved_at_ms AS resolved_at_ms
|
|
FROM "${APPROVED_ACTION_DISPATCH_TABLE}" AS dispatch
|
|
JOIN "${APPROVED_ACTION_DISPATCH_EXECUTION_TABLE}" AS execution
|
|
ON execution.dispatch_id = dispatch.id
|
|
JOIN "${APPROVED_ACTION_RECOVERY_CONTROL_TABLE}" AS recovery
|
|
ON recovery.dispatch_id = dispatch.id
|
|
LEFT JOIN "${APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE}" AS resolution
|
|
ON resolution.dispatch_id = dispatch.id`;
|
|
|
|
function numberOrNull(value: number | string | null): number | null {
|
|
return value === null ? null : Number(value);
|
|
}
|
|
|
|
function rowToAuthorizationFact(
|
|
row: AuthorizationFactRow,
|
|
): Readonly<ApprovedActionRecoveryAuthorizationFact> {
|
|
return normalizeApprovedActionRecoveryAuthorizationFact({
|
|
dispatchId: row.dispatch_id,
|
|
projectId: row.project_id,
|
|
mutationId: row.mutation_id,
|
|
resolvedBy: { type: 'user', id: row.resolved_by_id },
|
|
authenticationId: row.authentication_id,
|
|
assurance:
|
|
row.assurance as ApprovedActionRecoveryAuthorizationFact['assurance'],
|
|
authenticatedAtMs: Number(row.authenticated_at_ms),
|
|
projectVersion: Number(row.project_version),
|
|
bindingVersion: Number(row.binding_version),
|
|
authorizedAtMs: Number(row.authorized_at_ms),
|
|
factDigest: row.fact_digest,
|
|
});
|
|
}
|
|
|
|
function rowToRecoverySnapshot(
|
|
row: RecoveryRow,
|
|
): Readonly<ApprovedActionRecoverySnapshot> {
|
|
try {
|
|
const dispatch = normalizeApprovedActionDispatchRecord({
|
|
id: row.dispatch_id,
|
|
approvalRequestId: row.approval_request_id,
|
|
approvalRequestVersion: Number(row.approval_request_version),
|
|
projectId: row.dispatch_project_id,
|
|
state: row.dispatch_state as ApprovedActionDispatchRecord['state'],
|
|
action: {
|
|
permission:
|
|
row.permission as ApprovedActionDispatchRecord['action']['permission'],
|
|
actionType: row.action_type,
|
|
actionRef: row.action_ref,
|
|
actionDigest: row.action_digest,
|
|
previewDigest: row.preview_digest,
|
|
},
|
|
requestedBy: {
|
|
type: row.requested_by_type as ApprovedActionDispatchRecord['requestedBy']['type'],
|
|
id: row.requested_by_id,
|
|
},
|
|
consumedBy: {
|
|
type: row.consumed_by_type as ApprovedActionDispatchRecord['consumedBy']['type'],
|
|
id: row.consumed_by_id,
|
|
},
|
|
createdAtMs: Number(row.dispatch_created_at_ms),
|
|
});
|
|
const execution = normalizeApprovedActionDispatchExecutionRecord({
|
|
dispatchId: row.dispatch_id,
|
|
projectId: row.execution_project_id,
|
|
status:
|
|
row.execution_status as ApprovedActionDispatchExecutionRecord['status'],
|
|
version: Number(row.execution_version),
|
|
attemptCount: Number(row.attempt_count),
|
|
maxAttempts: Number(row.max_attempts),
|
|
eligibleAtMs: numberOrNull(row.eligible_at_ms),
|
|
nextAttemptAtMs: numberOrNull(row.next_attempt_at_ms),
|
|
leaseOwner: row.execution_lease_owner,
|
|
leaseToken: row.execution_lease_token,
|
|
leaseExpiresAtMs: numberOrNull(row.execution_lease_expires_at_ms),
|
|
startedAtMs: numberOrNull(row.started_at_ms),
|
|
resultMutationId: row.execution_result_mutation_id,
|
|
lastResultCode: row.execution_last_result_code,
|
|
completedAtMs: numberOrNull(row.completed_at_ms),
|
|
createdAtMs: Number(row.execution_created_at_ms),
|
|
updatedAtMs: Number(row.execution_updated_at_ms),
|
|
});
|
|
const recovery = normalizeApprovedActionRecoveryControlRecord({
|
|
dispatchId: row.dispatch_id,
|
|
projectId: row.recovery_project_id,
|
|
executionVersion: Number(row.recovery_execution_version),
|
|
status:
|
|
row.recovery_status as ApprovedActionRecoveryControlRecord['status'],
|
|
version: Number(row.recovery_version),
|
|
nextScanAtMs: numberOrNull(row.next_scan_at_ms),
|
|
leaseOwner: row.recovery_lease_owner,
|
|
leaseToken: row.recovery_lease_token,
|
|
leaseExpiresAtMs: numberOrNull(row.recovery_lease_expires_at_ms),
|
|
findingCount: Number(row.finding_count),
|
|
lastFindingMutationId: row.last_finding_mutation_id,
|
|
lastFinding:
|
|
row.last_finding as ApprovedActionRecoveryControlRecord['lastFinding'],
|
|
lastResultCode: row.recovery_last_result_code,
|
|
lastEvidenceDigest: row.last_evidence_digest,
|
|
resolutionMutationId: row.recovery_resolution_mutation_id,
|
|
createdAtMs: Number(row.recovery_created_at_ms),
|
|
updatedAtMs: Number(row.recovery_updated_at_ms),
|
|
});
|
|
const action: Readonly<ApprovedActionDispatchExecutionSnapshot> =
|
|
Object.freeze({ dispatch, execution });
|
|
let resolution: Readonly<ApprovedActionRecoveryResolutionRecord> | null =
|
|
null;
|
|
if (row.resolution_mutation_id !== null) {
|
|
if (
|
|
row.resolution_project_id === null ||
|
|
row.resolution_execution_version === null ||
|
|
row.resolution_source === null ||
|
|
row.resolution_decision === null ||
|
|
row.resolution_reason_code === null ||
|
|
row.resolved_at_ms === null
|
|
) {
|
|
throw new ApprovedActionRecoveryBindingConflictError();
|
|
}
|
|
resolution = normalizeApprovedActionRecoveryResolutionRecord({
|
|
dispatchId: row.dispatch_id,
|
|
projectId: row.resolution_project_id,
|
|
executionVersion: Number(row.resolution_execution_version),
|
|
mutationId: row.resolution_mutation_id,
|
|
source:
|
|
row.resolution_source as ApprovedActionRecoveryResolutionRecord['source'],
|
|
decision:
|
|
row.resolution_decision as ApprovedActionRecoveryResolutionRecord['decision'],
|
|
evidenceDigest: row.resolution_evidence_digest,
|
|
reasonCode: row.resolution_reason_code,
|
|
resolvedBy:
|
|
row.resolved_by_type === null || row.resolved_by_id === null
|
|
? null
|
|
: {
|
|
type: row.resolved_by_type as 'user',
|
|
id: row.resolved_by_id,
|
|
},
|
|
resolvedAtMs: Number(row.resolved_at_ms),
|
|
});
|
|
}
|
|
if (
|
|
dispatch.id !== execution.dispatchId ||
|
|
dispatch.id !== recovery.dispatchId ||
|
|
dispatch.projectId !== execution.projectId ||
|
|
dispatch.projectId !== recovery.projectId ||
|
|
recovery.executionVersion !== execution.version
|
|
) {
|
|
throw new ApprovedActionRecoveryBindingConflictError();
|
|
}
|
|
if (recovery.status === 'resolved') {
|
|
if (
|
|
!['succeeded', 'failed', 'blocked'].includes(execution.status) ||
|
|
execution.resultMutationId !== recovery.resolutionMutationId
|
|
) {
|
|
throw new ApprovedActionRecoveryBindingConflictError();
|
|
}
|
|
} else if (execution.status !== 'executing') {
|
|
throw new ApprovedActionRecoveryBindingConflictError();
|
|
}
|
|
if (
|
|
resolution &&
|
|
(resolution.dispatchId !== dispatch.id ||
|
|
resolution.projectId !== dispatch.projectId ||
|
|
resolution.executionVersion + 1 !== execution.version ||
|
|
resolution.mutationId !== recovery.resolutionMutationId ||
|
|
resolution.mutationId !== execution.resultMutationId)
|
|
) {
|
|
throw new ApprovedActionRecoveryBindingConflictError();
|
|
}
|
|
return Object.freeze({ action, recovery, resolution });
|
|
} catch (error) {
|
|
if (
|
|
error instanceof ApprovedActionRecoveryBindingConflictError ||
|
|
error instanceof ApprovedActionDispatchBindingConflictError
|
|
) {
|
|
throw error;
|
|
}
|
|
throw new ApprovedActionRecoveryRepositoryError();
|
|
}
|
|
}
|
|
|
|
function assertExactKeys(value: object, expected: readonly string[]): void {
|
|
const keys = Object.keys(value).sort();
|
|
const canonical = [...expected].sort();
|
|
if (
|
|
keys.length !== canonical.length ||
|
|
keys.some((key, index) => key !== canonical[index])
|
|
) {
|
|
throw new TypeError('Approved action recovery command shape is invalid');
|
|
}
|
|
}
|
|
|
|
function assertCommand(
|
|
value: unknown,
|
|
expected: readonly string[],
|
|
): asserts value is object {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new TypeError('Approved action recovery command must be an object');
|
|
}
|
|
assertExactKeys(value, expected);
|
|
}
|
|
|
|
function assertVersion(value: number): void {
|
|
if (!Number.isSafeInteger(value) || value < 0 || value >= 2_147_483_647) {
|
|
throw new RangeError('Approved action recovery version is invalid');
|
|
}
|
|
}
|
|
|
|
function errorCode(error: unknown): string | undefined {
|
|
if (!error || typeof error !== 'object') return undefined;
|
|
for (const candidate of [
|
|
error,
|
|
'original' in error ? error.original : undefined,
|
|
'parent' in error ? error.parent : undefined,
|
|
]) {
|
|
if (
|
|
candidate &&
|
|
typeof candidate === 'object' &&
|
|
'code' in candidate &&
|
|
typeof candidate.code === 'string'
|
|
) {
|
|
return candidate.code;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function retryDelay(attempt: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, 2 ** attempt));
|
|
}
|
|
|
|
function isKnownError(error: unknown): boolean {
|
|
return (
|
|
error instanceof ApprovedActionRecoveryBindingConflictError ||
|
|
error instanceof ApprovedActionRecoveryFenceRejectedError ||
|
|
error instanceof ApprovedActionRecoveryRepositoryError ||
|
|
error instanceof InvalidApprovalValueError ||
|
|
error instanceof RangeError ||
|
|
error instanceof TypeError
|
|
);
|
|
}
|
|
|
|
function terminalStatus(
|
|
decision: ResolveApprovedActionRecoveryCommand['decision'],
|
|
): 'succeeded' | 'failed' | 'blocked' {
|
|
if (decision === 'confirm_succeeded') return 'succeeded';
|
|
if (decision === 'confirm_failed') return 'failed';
|
|
return 'blocked';
|
|
}
|
|
|
|
export class LegacySequelizeApprovedActionRecoveryRepository
|
|
implements ApprovedActionRecoveryRepository
|
|
{
|
|
constructor(private readonly database: Sequelize) {
|
|
if (database.getDialect() !== 'sqlite') {
|
|
throw new TypeError(
|
|
'Approved action recovery repository is SQLite-only; cluster-control requires a PostgreSQL adapter',
|
|
);
|
|
}
|
|
}
|
|
|
|
private async transactionWithRetry<T>(
|
|
operation: (transaction: Transaction) => Promise<T>,
|
|
): Promise<T> {
|
|
for (let attempt = 0; attempt < RETRY_ATTEMPTS; attempt += 1) {
|
|
try {
|
|
return await this.database.transaction(
|
|
{ type: Transaction.TYPES.IMMEDIATE },
|
|
operation,
|
|
);
|
|
} catch (error) {
|
|
if (isKnownError(error)) throw error;
|
|
if (
|
|
errorCode(error) === 'SQLITE_BUSY' &&
|
|
attempt < RETRY_ATTEMPTS - 1
|
|
) {
|
|
await retryDelay(attempt);
|
|
continue;
|
|
}
|
|
throw new ApprovedActionRecoveryRepositoryError();
|
|
}
|
|
}
|
|
throw new ApprovedActionRecoveryRepositoryError();
|
|
}
|
|
|
|
private async selectSnapshot(
|
|
dispatchId: string,
|
|
transaction?: Transaction,
|
|
): Promise<Readonly<ApprovedActionRecoverySnapshot> | null> {
|
|
const rows = await this.database.query<RecoveryRow>(
|
|
`${RECOVERY_SELECT}
|
|
WHERE dispatch.id = :dispatchId
|
|
LIMIT 2`,
|
|
{
|
|
type: QueryTypes.SELECT,
|
|
replacements: { dispatchId },
|
|
...(transaction ? { transaction } : {}),
|
|
},
|
|
);
|
|
if (rows.length === 0) {
|
|
const executions = await this.database.query<{
|
|
status: string;
|
|
}>(
|
|
`SELECT status
|
|
FROM "${APPROVED_ACTION_DISPATCH_EXECUTION_TABLE}"
|
|
WHERE dispatch_id = :dispatchId
|
|
LIMIT 2`,
|
|
{
|
|
type: QueryTypes.SELECT,
|
|
replacements: { dispatchId },
|
|
...(transaction ? { transaction } : {}),
|
|
},
|
|
);
|
|
if (
|
|
executions.length !== 0 &&
|
|
(executions.length !== 1 || executions[0].status === 'executing')
|
|
) {
|
|
throw new ApprovedActionRecoveryRepositoryError();
|
|
}
|
|
return null;
|
|
}
|
|
if (rows.length !== 1) throw new ApprovedActionRecoveryRepositoryError();
|
|
return rowToRecoverySnapshot(rows[0]);
|
|
}
|
|
|
|
private async updateControl(
|
|
dispatchId: string,
|
|
expectedExecutionVersion: number,
|
|
expectedRecoveryVersion: number,
|
|
expectedStatuses: readonly string[],
|
|
values: Record<string, string | number | null>,
|
|
transaction: Transaction,
|
|
fence?: { owner: string; leaseToken: string },
|
|
): Promise<void> {
|
|
const assignments = Object.keys(values)
|
|
.map((field) => `"${field}" = :${field}`)
|
|
.join(', ');
|
|
const statusParameters = Object.fromEntries(
|
|
expectedStatuses.map((status, index) => [
|
|
`expectedStatus${index}`,
|
|
status,
|
|
]),
|
|
);
|
|
const statusList = expectedStatuses
|
|
.map((_, index) => `:expectedStatus${index}`)
|
|
.join(', ');
|
|
const [, affected] = await this.database.query(
|
|
`UPDATE "${APPROVED_ACTION_RECOVERY_CONTROL_TABLE}"
|
|
SET ${assignments}
|
|
WHERE dispatch_id = :dispatchId
|
|
AND execution_version = :expectedExecutionVersion
|
|
AND version = :expectedRecoveryVersion
|
|
AND status IN (${statusList})
|
|
${
|
|
fence
|
|
? 'AND lease_owner = :owner AND lease_token = :leaseToken'
|
|
: ''
|
|
}`,
|
|
{
|
|
type: QueryTypes.UPDATE,
|
|
replacements: {
|
|
...values,
|
|
dispatchId,
|
|
expectedExecutionVersion,
|
|
expectedRecoveryVersion,
|
|
...statusParameters,
|
|
...(fence ?? {}),
|
|
},
|
|
transaction,
|
|
},
|
|
);
|
|
if (affected !== 1) throw new ApprovedActionRecoveryFenceRejectedError();
|
|
}
|
|
|
|
private async selectAuthorizationFact(
|
|
dispatchId: string,
|
|
transaction: Transaction,
|
|
): Promise<Readonly<ApprovedActionRecoveryAuthorizationFact> | null> {
|
|
const rows = await this.database.query<AuthorizationFactRow>(
|
|
`SELECT dispatch_id, project_id, mutation_id, resolved_by_id,
|
|
authentication_id, assurance, authenticated_at_ms,
|
|
project_version, binding_version, authorized_at_ms, fact_digest
|
|
FROM "${APPROVED_ACTION_RECOVERY_AUTHORIZATION_TABLE}"
|
|
WHERE dispatch_id = :dispatchId
|
|
LIMIT 2`,
|
|
{
|
|
type: QueryTypes.SELECT,
|
|
replacements: { dispatchId },
|
|
transaction,
|
|
},
|
|
);
|
|
if (rows.length > 1) throw new ApprovedActionRecoveryRepositoryError();
|
|
return rows.length === 0 ? null : rowToAuthorizationFact(rows[0]);
|
|
}
|
|
|
|
private async assertAuthorizationFence(
|
|
fact: Readonly<ApprovedActionRecoveryAuthorizationFact>,
|
|
transaction: Transaction,
|
|
): Promise<void> {
|
|
const rows = await this.database.query<PolicyFenceRow>(
|
|
`SELECT project.version AS project_version,
|
|
(SELECT MAX(binding.version)
|
|
FROM "${PROJECT_ROLE_BINDING_TABLE}" AS binding
|
|
WHERE binding.project_id = project.id
|
|
AND binding.subject_type = 'user'
|
|
AND binding.subject_id = :subjectId) AS binding_version
|
|
FROM "${PROJECT_TABLE}" AS project
|
|
WHERE project.id = :projectId
|
|
LIMIT 2`,
|
|
{
|
|
type: QueryTypes.SELECT,
|
|
replacements: {
|
|
projectId: fact.projectId,
|
|
subjectId: fact.resolvedBy.id,
|
|
},
|
|
transaction,
|
|
},
|
|
);
|
|
if (
|
|
rows.length !== 1 ||
|
|
Number(rows[0].project_version) !== fact.projectVersion ||
|
|
rows[0].binding_version === null ||
|
|
Number(rows[0].binding_version) !== fact.bindingVersion
|
|
) {
|
|
throw new ApprovedActionRecoveryFenceRejectedError();
|
|
}
|
|
}
|
|
|
|
private async insertAuthorizationFact(
|
|
fact: Readonly<ApprovedActionRecoveryAuthorizationFact>,
|
|
transaction: Transaction,
|
|
): Promise<void> {
|
|
await this.database.query(
|
|
`INSERT INTO "${APPROVED_ACTION_RECOVERY_AUTHORIZATION_TABLE}"
|
|
(dispatch_id, project_id, mutation_id, resolved_by_id,
|
|
authentication_id, assurance, authenticated_at_ms, project_version,
|
|
binding_version, authorized_at_ms, fact_digest)
|
|
VALUES
|
|
(:dispatchId, :projectId, :mutationId, :resolvedById,
|
|
:authenticationId, :assurance, :authenticatedAtMs, :projectVersion,
|
|
:bindingVersion, :authorizedAtMs, :factDigest)`,
|
|
{
|
|
replacements: {
|
|
dispatchId: fact.dispatchId,
|
|
projectId: fact.projectId,
|
|
mutationId: fact.mutationId,
|
|
resolvedById: fact.resolvedBy.id,
|
|
authenticationId: fact.authenticationId,
|
|
assurance: fact.assurance,
|
|
authenticatedAtMs: fact.authenticatedAtMs,
|
|
projectVersion: fact.projectVersion,
|
|
bindingVersion: fact.bindingVersion,
|
|
authorizedAtMs: fact.authorizedAtMs,
|
|
factDigest: fact.factDigest,
|
|
},
|
|
transaction,
|
|
},
|
|
);
|
|
}
|
|
|
|
async findById(
|
|
dispatchId: string,
|
|
): Promise<Readonly<ApprovedActionRecoverySnapshot> | null> {
|
|
assertApprovalMutationId(dispatchId);
|
|
return this.selectSnapshot(dispatchId);
|
|
}
|
|
|
|
async listDue(
|
|
query: ListDueApprovedActionRecoveriesQuery,
|
|
): Promise<ListDueApprovedActionRecoveriesResult> {
|
|
assertCommand(
|
|
query,
|
|
query.cursor === undefined
|
|
? ['nowMs', 'limit']
|
|
: ['nowMs', 'limit', 'cursor'],
|
|
);
|
|
assertApprovalTimestamp('nowMs', query.nowMs);
|
|
assertApprovedActionRecoveryPageSize(query.limit);
|
|
const cursor = query.cursor
|
|
? normalizeApprovedActionRecoveryCursor(query.cursor)
|
|
: undefined;
|
|
const rows = await this.database.query<RecoveryRow>(
|
|
`${RECOVERY_SELECT}
|
|
WHERE execution.status = 'executing'
|
|
AND execution.lease_expires_at_ms IS NOT NULL
|
|
AND execution.lease_expires_at_ms <= :nowMs
|
|
AND recovery.status IN ('armed', 'leased')
|
|
AND recovery.next_scan_at_ms IS NOT NULL
|
|
AND recovery.next_scan_at_ms <= :nowMs
|
|
${
|
|
cursor
|
|
? `AND (recovery.next_scan_at_ms > :cursorAt
|
|
OR (recovery.next_scan_at_ms = :cursorAt
|
|
AND dispatch.id > :cursorId))`
|
|
: ''
|
|
}
|
|
ORDER BY recovery.next_scan_at_ms ASC, dispatch.id ASC
|
|
LIMIT :rowLimit`,
|
|
{
|
|
type: QueryTypes.SELECT,
|
|
replacements: {
|
|
nowMs: query.nowMs,
|
|
rowLimit: query.limit + 1,
|
|
...(cursor
|
|
? { cursorAt: cursor.nextScanAtMs, cursorId: cursor.dispatchId }
|
|
: {}),
|
|
},
|
|
},
|
|
);
|
|
const truncated = rows.length > query.limit;
|
|
const selected = rows.slice(0, query.limit).map(rowToRecoverySnapshot);
|
|
const last = selected[selected.length - 1];
|
|
return Object.freeze({
|
|
recoveries: Object.freeze(selected),
|
|
truncated,
|
|
...(truncated && last
|
|
? {
|
|
nextCursor: Object.freeze({
|
|
nextScanAtMs: last.recovery.nextScanAtMs!,
|
|
dispatchId: last.action.dispatch.id,
|
|
}),
|
|
}
|
|
: {}),
|
|
});
|
|
}
|
|
|
|
async claim(
|
|
command: ClaimApprovedActionRecoveryCommand,
|
|
): Promise<ClaimApprovedActionRecoveryResult> {
|
|
assertCommand(command, [
|
|
'dispatchId',
|
|
'owner',
|
|
'leaseToken',
|
|
'nowMs',
|
|
'leaseDurationMs',
|
|
]);
|
|
assertApprovalMutationId(command.dispatchId);
|
|
assertApprovedActionLeaseIdentity(command.owner);
|
|
assertApprovedActionLeaseIdentity(command.leaseToken);
|
|
assertApprovedActionRecoveryLeaseDuration(command.leaseDurationMs);
|
|
assertApprovalTimestamp('nowMs', command.nowMs);
|
|
return this.transactionWithRetry(async (transaction) => {
|
|
const snapshot = await this.selectSnapshot(
|
|
command.dispatchId,
|
|
transaction,
|
|
);
|
|
if (!snapshot) return { status: 'not_found' as const };
|
|
const current = snapshot.recovery;
|
|
if (current.status === 'resolved') {
|
|
return { status: 'resolved' as const, snapshot };
|
|
}
|
|
if (current.status === 'manual_required') {
|
|
return { status: 'manual_required' as const, snapshot };
|
|
}
|
|
const execution = snapshot.action.execution;
|
|
if (
|
|
execution.status !== 'executing' ||
|
|
execution.leaseExpiresAtMs === null ||
|
|
execution.leaseExpiresAtMs > command.nowMs
|
|
) {
|
|
return { status: 'execution_active' as const, snapshot };
|
|
}
|
|
if (
|
|
current.status === 'leased' &&
|
|
current.leaseOwner === command.owner &&
|
|
current.leaseToken === command.leaseToken &&
|
|
current.leaseExpiresAtMs! > command.nowMs
|
|
) {
|
|
return { status: 'claimed' as const, snapshot };
|
|
}
|
|
if (
|
|
current.status === 'leased' &&
|
|
current.leaseExpiresAtMs! > command.nowMs
|
|
) {
|
|
return { status: 'leased' as const, snapshot };
|
|
}
|
|
if (
|
|
current.nextScanAtMs === null ||
|
|
current.nextScanAtMs > command.nowMs
|
|
) {
|
|
return { status: 'not_due' as const, snapshot };
|
|
}
|
|
const leaseExpiresAtMs = command.nowMs + command.leaseDurationMs;
|
|
if (!Number.isSafeInteger(leaseExpiresAtMs)) {
|
|
throw new RangeError(
|
|
'Approved action recovery lease expiry is invalid',
|
|
);
|
|
}
|
|
await this.updateControl(
|
|
command.dispatchId,
|
|
current.executionVersion,
|
|
current.version,
|
|
[current.status],
|
|
{
|
|
status: 'leased',
|
|
version: current.version + 1,
|
|
next_scan_at_ms: leaseExpiresAtMs,
|
|
lease_owner: command.owner,
|
|
lease_token: command.leaseToken,
|
|
lease_expires_at_ms: leaseExpiresAtMs,
|
|
updated_at_ms: command.nowMs,
|
|
},
|
|
transaction,
|
|
);
|
|
return {
|
|
status: 'claimed' as const,
|
|
snapshot: (await this.selectSnapshot(command.dispatchId, transaction))!,
|
|
};
|
|
});
|
|
}
|
|
|
|
async recordFinding(
|
|
command: RecordApprovedActionRecoveryFindingCommand,
|
|
): Promise<Readonly<ApprovedActionRecoverySnapshot>> {
|
|
assertCommand(
|
|
command,
|
|
command.evidenceDigest === undefined && command.retryAtMs === undefined
|
|
? [
|
|
'dispatchId',
|
|
'expectedExecutionVersion',
|
|
'expectedRecoveryVersion',
|
|
'owner',
|
|
'leaseToken',
|
|
'findingMutationId',
|
|
'finding',
|
|
'resultCode',
|
|
'observedAtMs',
|
|
]
|
|
: [
|
|
'dispatchId',
|
|
'expectedExecutionVersion',
|
|
'expectedRecoveryVersion',
|
|
'owner',
|
|
'leaseToken',
|
|
'findingMutationId',
|
|
'finding',
|
|
'resultCode',
|
|
...(command.evidenceDigest === undefined ? [] : ['evidenceDigest']),
|
|
'observedAtMs',
|
|
...(command.retryAtMs === undefined ? [] : ['retryAtMs']),
|
|
],
|
|
);
|
|
assertApprovalMutationId(command.dispatchId);
|
|
assertVersion(command.expectedExecutionVersion);
|
|
assertVersion(command.expectedRecoveryVersion);
|
|
assertApprovalMutationId(command.findingMutationId);
|
|
assertApprovedActionLeaseIdentity(command.owner);
|
|
assertApprovedActionLeaseIdentity(command.leaseToken);
|
|
if (
|
|
![
|
|
'still_running',
|
|
'missing',
|
|
'conflict',
|
|
'unsupported',
|
|
'unavailable',
|
|
].includes(command.finding)
|
|
) {
|
|
throw new TypeError('Approved action recovery finding is invalid');
|
|
}
|
|
if (command.evidenceDigest !== undefined) {
|
|
assertApprovedActionEvidenceDigest(command.evidenceDigest);
|
|
}
|
|
assertApprovedActionResultCode(command.resultCode);
|
|
assertApprovalTimestamp('observedAtMs', command.observedAtMs);
|
|
const retryable = ['still_running', 'missing', 'unavailable'].includes(
|
|
command.finding,
|
|
);
|
|
if (retryable !== (command.retryAtMs !== undefined)) {
|
|
throw new TypeError('Approved action recovery retry shape is invalid');
|
|
}
|
|
if (command.retryAtMs !== undefined) {
|
|
assertApprovalTimestamp('retryAtMs', command.retryAtMs);
|
|
if (command.retryAtMs <= command.observedAtMs) {
|
|
throw new RangeError('Approved action recovery retry must be future');
|
|
}
|
|
}
|
|
return this.transactionWithRetry(async (transaction) => {
|
|
const snapshot = await this.selectSnapshot(
|
|
command.dispatchId,
|
|
transaction,
|
|
);
|
|
if (!snapshot) throw new ApprovedActionRecoveryFenceRejectedError();
|
|
const current = snapshot.recovery;
|
|
const nextStatus = retryable ? 'armed' : 'manual_required';
|
|
if (
|
|
current.version === command.expectedRecoveryVersion + 1 &&
|
|
current.executionVersion === command.expectedExecutionVersion &&
|
|
current.status === nextStatus &&
|
|
current.lastFindingMutationId === command.findingMutationId &&
|
|
current.lastFinding === command.finding &&
|
|
current.lastResultCode === command.resultCode &&
|
|
current.lastEvidenceDigest === (command.evidenceDigest ?? null) &&
|
|
current.nextScanAtMs === (command.retryAtMs ?? null) &&
|
|
current.updatedAtMs === command.observedAtMs
|
|
) {
|
|
return snapshot;
|
|
}
|
|
if (
|
|
snapshot.action.execution.status !== 'executing' ||
|
|
snapshot.action.execution.version !== command.expectedExecutionVersion
|
|
) {
|
|
throw new ApprovedActionRecoveryFenceRejectedError();
|
|
}
|
|
await this.updateControl(
|
|
command.dispatchId,
|
|
command.expectedExecutionVersion,
|
|
command.expectedRecoveryVersion,
|
|
['leased'],
|
|
{
|
|
status: nextStatus,
|
|
version: command.expectedRecoveryVersion + 1,
|
|
next_scan_at_ms: command.retryAtMs ?? null,
|
|
lease_owner: null,
|
|
lease_token: null,
|
|
lease_expires_at_ms: null,
|
|
finding_count: Math.min(
|
|
MAX_APPROVED_ACTION_RECOVERY_FINDINGS,
|
|
current.findingCount + 1,
|
|
),
|
|
last_finding_mutation_id: command.findingMutationId,
|
|
last_finding: command.finding,
|
|
last_result_code: command.resultCode,
|
|
last_evidence_digest: command.evidenceDigest ?? null,
|
|
updated_at_ms: command.observedAtMs,
|
|
},
|
|
transaction,
|
|
{ owner: command.owner, leaseToken: command.leaseToken },
|
|
);
|
|
return (await this.selectSnapshot(command.dispatchId, transaction))!;
|
|
});
|
|
}
|
|
|
|
async resolve(
|
|
command: ResolveApprovedActionRecoveryCommand,
|
|
): Promise<ResolveApprovedActionRecoveryResult> {
|
|
assertCommand(
|
|
command,
|
|
command.source === 'automatic_evidence'
|
|
? [
|
|
'dispatchId',
|
|
'expectedExecutionVersion',
|
|
'expectedRecoveryVersion',
|
|
'owner',
|
|
'leaseToken',
|
|
'mutationId',
|
|
'source',
|
|
'decision',
|
|
'evidenceDigest',
|
|
'reasonCode',
|
|
'resolvedAtMs',
|
|
]
|
|
: [
|
|
'dispatchId',
|
|
'expectedExecutionVersion',
|
|
'expectedRecoveryVersion',
|
|
'mutationId',
|
|
'source',
|
|
'decision',
|
|
...(command.evidenceDigest === undefined ? [] : ['evidenceDigest']),
|
|
'reasonCode',
|
|
'resolvedBy',
|
|
'resolvedAtMs',
|
|
'authorizationFact',
|
|
],
|
|
);
|
|
assertApprovalMutationId(command.dispatchId);
|
|
assertVersion(command.expectedExecutionVersion);
|
|
assertVersion(command.expectedRecoveryVersion);
|
|
assertApprovalMutationId(command.mutationId);
|
|
assertApprovedActionResultCode(command.reasonCode);
|
|
if (command.source === 'automatic_evidence') {
|
|
assertApprovedActionLeaseIdentity(command.owner);
|
|
assertApprovedActionLeaseIdentity(command.leaseToken);
|
|
}
|
|
if (command.evidenceDigest !== undefined) {
|
|
assertApprovedActionEvidenceDigest(command.evidenceDigest);
|
|
}
|
|
assertApprovalTimestamp('resolvedAtMs', command.resolvedAtMs);
|
|
const authorizationFact =
|
|
command.source === 'human'
|
|
? normalizeApprovedActionRecoveryAuthorizationFact(
|
|
command.authorizationFact,
|
|
)
|
|
: null;
|
|
if (command.source === 'human') normalizePolicySubject(command.resolvedBy);
|
|
return this.transactionWithRetry(async (transaction) => {
|
|
const snapshot = await this.selectSnapshot(
|
|
command.dispatchId,
|
|
transaction,
|
|
);
|
|
if (!snapshot) return { status: 'not_found' as const };
|
|
const expectedResolution =
|
|
normalizeApprovedActionRecoveryResolutionRecord({
|
|
dispatchId: command.dispatchId,
|
|
projectId: snapshot.action.dispatch.projectId,
|
|
executionVersion: command.expectedExecutionVersion,
|
|
mutationId: command.mutationId,
|
|
source: command.source,
|
|
decision: command.decision,
|
|
evidenceDigest: command.evidenceDigest ?? null,
|
|
reasonCode: command.reasonCode,
|
|
resolvedBy: command.source === 'human' ? command.resolvedBy : null,
|
|
resolvedAtMs: command.resolvedAtMs,
|
|
});
|
|
if (snapshot.resolution) {
|
|
if (
|
|
JSON.stringify(snapshot.resolution) ===
|
|
JSON.stringify(expectedResolution)
|
|
) {
|
|
if (command.source === 'human') {
|
|
const storedAuthorization = await this.selectAuthorizationFact(
|
|
command.dispatchId,
|
|
transaction,
|
|
);
|
|
if (
|
|
!storedAuthorization ||
|
|
JSON.stringify(storedAuthorization) !==
|
|
JSON.stringify(authorizationFact)
|
|
) {
|
|
return { status: 'already_terminal' as const, snapshot };
|
|
}
|
|
}
|
|
return { status: 'resolved' as const, snapshot };
|
|
}
|
|
return { status: 'already_terminal' as const, snapshot };
|
|
}
|
|
if (
|
|
snapshot.action.execution.status !== 'executing' ||
|
|
snapshot.recovery.status === 'resolved'
|
|
) {
|
|
return { status: 'already_terminal' as const, snapshot };
|
|
}
|
|
if (
|
|
snapshot.action.execution.version !==
|
|
command.expectedExecutionVersion ||
|
|
snapshot.recovery.executionVersion !==
|
|
command.expectedExecutionVersion ||
|
|
snapshot.recovery.version !== command.expectedRecoveryVersion ||
|
|
snapshot.action.execution.startedAtMs === null ||
|
|
snapshot.action.execution.leaseExpiresAtMs === null ||
|
|
command.resolvedAtMs < snapshot.action.execution.leaseExpiresAtMs ||
|
|
command.resolvedAtMs < snapshot.action.execution.startedAtMs
|
|
) {
|
|
throw new ApprovedActionRecoveryFenceRejectedError();
|
|
}
|
|
if (
|
|
command.source === 'automatic_evidence' &&
|
|
(snapshot.recovery.status !== 'leased' ||
|
|
snapshot.recovery.leaseOwner !== command.owner ||
|
|
snapshot.recovery.leaseToken !== command.leaseToken)
|
|
) {
|
|
throw new ApprovedActionRecoveryFenceRejectedError();
|
|
}
|
|
if (command.source === 'human') {
|
|
if (
|
|
authorizationFact === null ||
|
|
authorizationFact.dispatchId !== command.dispatchId ||
|
|
authorizationFact.projectId !== snapshot.action.dispatch.projectId ||
|
|
authorizationFact.mutationId !== command.mutationId ||
|
|
authorizationFact.resolvedBy.type !== 'user' ||
|
|
authorizationFact.resolvedBy.id !== command.resolvedBy.id ||
|
|
authorizationFact.authorizedAtMs !== command.resolvedAtMs
|
|
) {
|
|
throw new ApprovedActionRecoveryFenceRejectedError();
|
|
}
|
|
await this.assertAuthorizationFence(authorizationFact, transaction);
|
|
}
|
|
const nextExecutionVersion = command.expectedExecutionVersion + 1;
|
|
const nextRecoveryVersion = command.expectedRecoveryVersion + 1;
|
|
const status = terminalStatus(command.decision);
|
|
const [, executionAffected] = await this.database.query(
|
|
`UPDATE "${APPROVED_ACTION_DISPATCH_EXECUTION_TABLE}"
|
|
SET status = :status,
|
|
version = :nextExecutionVersion,
|
|
eligible_at_ms = NULL,
|
|
next_attempt_at_ms = NULL,
|
|
lease_owner = NULL,
|
|
lease_token = NULL,
|
|
lease_expires_at_ms = NULL,
|
|
result_mutation_id = :mutationId,
|
|
last_result_code = :reasonCode,
|
|
completed_at_ms = :resolvedAtMs,
|
|
updated_at_ms = :resolvedAtMs
|
|
WHERE dispatch_id = :dispatchId
|
|
AND status = 'executing'
|
|
AND version = :expectedExecutionVersion`,
|
|
{
|
|
type: QueryTypes.UPDATE,
|
|
replacements: {
|
|
dispatchId: command.dispatchId,
|
|
expectedExecutionVersion: command.expectedExecutionVersion,
|
|
nextExecutionVersion,
|
|
mutationId: command.mutationId,
|
|
reasonCode: command.reasonCode,
|
|
resolvedAtMs: command.resolvedAtMs,
|
|
status,
|
|
},
|
|
transaction,
|
|
},
|
|
);
|
|
if (executionAffected !== 1) {
|
|
throw new ApprovedActionRecoveryFenceRejectedError();
|
|
}
|
|
const automatic = command.source === 'automatic_evidence';
|
|
await this.updateControl(
|
|
command.dispatchId,
|
|
command.expectedExecutionVersion,
|
|
command.expectedRecoveryVersion,
|
|
automatic ? ['leased'] : ['armed', 'leased', 'manual_required'],
|
|
{
|
|
execution_version: nextExecutionVersion,
|
|
status: 'resolved',
|
|
version: nextRecoveryVersion,
|
|
next_scan_at_ms: null,
|
|
lease_owner: null,
|
|
lease_token: null,
|
|
lease_expires_at_ms: null,
|
|
...(automatic
|
|
? {
|
|
finding_count: Math.min(
|
|
MAX_APPROVED_ACTION_RECOVERY_FINDINGS,
|
|
snapshot.recovery.findingCount + 1,
|
|
),
|
|
last_finding_mutation_id: command.mutationId,
|
|
last_finding:
|
|
command.decision === 'confirm_succeeded'
|
|
? 'verified_succeeded'
|
|
: 'verified_failed',
|
|
last_result_code: command.reasonCode,
|
|
last_evidence_digest: command.evidenceDigest,
|
|
}
|
|
: {}),
|
|
resolution_mutation_id: command.mutationId,
|
|
updated_at_ms: command.resolvedAtMs,
|
|
},
|
|
transaction,
|
|
automatic
|
|
? { owner: command.owner, leaseToken: command.leaseToken }
|
|
: undefined,
|
|
);
|
|
await this.database.query(
|
|
`INSERT INTO "${APPROVED_ACTION_RECOVERY_RESOLUTION_TABLE}"
|
|
(dispatch_id, project_id, execution_version, mutation_id, source,
|
|
decision, evidence_digest, reason_code, resolved_by_type,
|
|
resolved_by_id, resolved_at_ms)
|
|
VALUES
|
|
(:dispatchId, :projectId, :executionVersion, :mutationId, :source,
|
|
:decision, :evidenceDigest, :reasonCode, :resolvedByType,
|
|
:resolvedById, :resolvedAtMs)`,
|
|
{
|
|
replacements: {
|
|
dispatchId: expectedResolution.dispatchId,
|
|
projectId: expectedResolution.projectId,
|
|
executionVersion: expectedResolution.executionVersion,
|
|
mutationId: expectedResolution.mutationId,
|
|
source: expectedResolution.source,
|
|
decision: expectedResolution.decision,
|
|
evidenceDigest: expectedResolution.evidenceDigest,
|
|
reasonCode: expectedResolution.reasonCode,
|
|
resolvedByType: expectedResolution.resolvedBy?.type ?? null,
|
|
resolvedById: expectedResolution.resolvedBy?.id ?? null,
|
|
resolvedAtMs: expectedResolution.resolvedAtMs,
|
|
},
|
|
transaction,
|
|
},
|
|
);
|
|
if (authorizationFact !== null) {
|
|
await this.insertAuthorizationFact(authorizationFact, transaction);
|
|
}
|
|
return {
|
|
status: 'resolved' as const,
|
|
snapshot: (await this.selectSnapshot(command.dispatchId, transaction))!,
|
|
};
|
|
});
|
|
}
|
|
}
|